Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion packages/llm/src/tool-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,9 +297,34 @@ const dispatch = (
)
}

// Effect's toJsonSchema (tool.ts line 183) emits bare properties for single-field structs
// and $defs/$ref for multi-field ones — resolve whichever shape is present.
const resolveProperties = (parameters: Record<string, unknown> | undefined): Record<string, unknown> | undefined => {
if (parameters?.properties) return parameters.properties as Record<string, unknown>
if (typeof parameters?.$ref === "string" && parameters.$defs) {
const refName = parameters.$ref.split("/").pop()
if (refName) {
const def = (parameters.$defs as Record<string, unknown>)[refName]
if (def && typeof def === "object") return (def as Record<string, unknown>).properties as Record<string, unknown>
}
}
return undefined
}

const schemaErrorMessage = (name: string, input: unknown, parameters: Record<string, unknown> | undefined, schemaError: string) => {
const expected = resolveProperties(parameters)
const expectedKeys = expected ? Object.keys(expected) : undefined
const receivedKeys = input !== null && typeof input === "object" ? Object.keys(input as Record<string, unknown>) : undefined
if (!expectedKeys && !receivedKeys) return `Invalid tool input for "${name}": ${schemaError}`
const parts = [`Invalid tool input for "${name}": ${schemaError}`]
if (expectedKeys) parts.push(`Expected keys: [${expectedKeys.join(", ")}]`)
if (receivedKeys) parts.push(`Received keys: [${receivedKeys.join(", ")}]`)
return parts.join(". ")
}

const decodeAndExecute = (tool: AnyTool, call: ToolCallPart): Effect.Effect<ToolResultValueType, ToolFailure> =>
tool._decode(call.input).pipe(
Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })),
Effect.mapError((error) => new ToolFailure({ message: schemaErrorMessage(call.name, call.input, tool._definition.inputSchema as Record<string, unknown> | undefined, error.message) })),
Effect.flatMap((decoded) => tool.execute!(decoded, { id: call.id, name: call.name })),
Effect.flatMap((value) =>
tool._encode(value).pipe(
Expand Down
106 changes: 106 additions & 0 deletions packages/llm/test/tool-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,31 @@ const schema_only_weather = tool({
success: Schema.Struct({ temperature: Schema.Number, condition: Schema.String }),
})

// Mirrors the real write tool schema — the most-reported failure surface for
// OpenAI-compatible local models (Qwen, DeepSeek) that emit wrong key names.
const write_file = tool({
description: "Write content to a file.",
parameters: Schema.Struct({
content: Schema.String,
filePath: Schema.String,
}),
success: Schema.Struct({ output: Schema.String }),
execute: ({ filePath }) => Effect.succeed({ output: `Wrote ${filePath}` }),
})

// Mirrors the real edit tool schema — covers the missing-required-key failure
// mode where the model runs out of token budget mid-generation.
const edit_file = tool({
description: "Edit a file by replacing a string.",
parameters: Schema.Struct({
filePath: Schema.String,
oldString: Schema.String,
newString: Schema.String,
}),
success: Schema.Struct({ output: Schema.String }),
execute: ({ filePath }) => Effect.succeed({ output: `Edited ${filePath}` }),
})

describe("LLMClient tools", () => {
it.effect("uses the registered model route when adding runtime tools", () =>
Effect.gen(function* () {
Expand Down Expand Up @@ -430,6 +455,87 @@ describe("LLMClient tools", () => {
}),
)

it.effect("includes expected and received keys in schema error message when wrong keys are sent", () =>
Effect.gen(function* () {
const layer = scriptedResponses([
sseEvents(toolCallChunk("call_1", "get_weather", '{"cityName":"Paris"}'), finishChunk("tool_calls")),
sseEvents(deltaChunk({ role: "assistant", content: "Done." }), finishChunk("stop")),
])

const events = Array.from(
yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe(
Stream.runCollect,
Effect.provide(layer),
),
)

const toolError = events.find(LLMEvent.is.toolError)
expect(toolError?.message).toContain("get_weather")
expect(toolError?.message).toContain("city")
expect(toolError?.message).toContain("cityName")
}),
)

it.effect("emits write tool schema error naming expected and received keys when model uses wrong key names", () =>
Effect.gen(function* () {
// OpenAI-compatible local models (Qwen, DeepSeek) commonly emit "fileContent"
// and "path" instead of the declared "content" and "filePath". The error fed
// back to the model must name both sides so it can self-correct on the next turn.
const layer = scriptedResponses([
sseEvents(
toolCallChunk("call_1", "write_file", '{"fileContent":"hello world","path":"/tmp/x.ts"}'),
finishChunk("tool_calls"),
),
sseEvents(deltaChunk({ role: "assistant", content: "Done." }), finishChunk("stop")),
])

const events = Array.from(
yield* TestToolRuntime.runTools({ request: baseRequest, tools: { write_file } }).pipe(
Stream.runCollect,
Effect.provide(layer),
),
)

const toolError = events.find(LLMEvent.is.toolError)
expect(toolError?.message).toContain("write_file")
// Model knows what it should have sent
expect(toolError?.message).toContain("content")
expect(toolError?.message).toContain("filePath")
// Model knows what it actually sent wrong
expect(toolError?.message).toContain("fileContent")
expect(toolError?.message).toContain("path")
}),
)

it.effect("emits edit tool schema error when model omits a required key due to token budget truncation", () =>
Effect.gen(function* () {
// When a model runs out of token budget mid-generation it can produce a
// partial tool call — filePath and oldString present but newString missing.
// The error must list all expected keys so the model retries with the full set.
const layer = scriptedResponses([
sseEvents(
toolCallChunk("call_1", "edit_file", '{"filePath":"/tmp/x.ts","oldString":"foo"}'),
finishChunk("tool_calls"),
),
sseEvents(deltaChunk({ role: "assistant", content: "Done." }), finishChunk("stop")),
])

const events = Array.from(
yield* TestToolRuntime.runTools({ request: baseRequest, tools: { edit_file } }).pipe(
Stream.runCollect,
Effect.provide(layer),
),
)

const toolError = events.find(LLMEvent.is.toolError)
expect(toolError?.message).toContain("edit_file")
// All three required keys are named so the model knows the full contract
expect(toolError?.message).toContain("filePath")
expect(toolError?.message).toContain("oldString")
expect(toolError?.message).toContain("newString")
}),
)

it.effect("emits tool-error when the handler returns a ToolFailure", () =>
Effect.gen(function* () {
const layer = scriptedResponses([
Expand Down
38 changes: 28 additions & 10 deletions packages/opencode/src/cli/cmd/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,11 +486,16 @@ export const ProvidersLoginCommand = effectCmd({
})

export const ProvidersLogoutCommand = effectCmd({
command: "logout",
command: "logout [provider]",
describe: "log out from a configured provider",
// Removes a global auth credential; no project instance needed.
instance: false,
handler: Effect.fn("Cli.providers.logout")(function* (_args) {
builder: (yargs) =>
yargs.positional("provider", {
describe: "provider id or name to log out from (skips provider selection)",
type: "string",
}),
handler: Effect.fn("Cli.providers.logout")(function* (args) {
const authSvc = yield* Auth.Service
const modelsDev = yield* ModelsDev.Service

Expand All @@ -502,14 +507,27 @@ export const ProvidersLogoutCommand = effectCmd({
return
}
const database = yield* modelsDev.get()
const selected = yield* Prompt.select({
message: "Select provider",
options: credentials.map(([key, value]) => ({
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
value: key,
})),
})
yield* Effect.orDie(authSvc.remove(yield* promptValue(selected)))

if (args.provider) {
const match = credentials.find(
([key]) => key === args.provider || database[key]?.name?.toLowerCase() === args.provider.toLowerCase(),
)
if (!match) return yield* fail(`Unknown provider "${args.provider}"`)
yield* Effect.orDie(authSvc.remove(match[0]))
yield* Prompt.outro("Logout successful")
return
}

const provider = yield* promptValue(
yield* Prompt.select({
message: "Select provider",
options: credentials.map(([key, value]) => ({
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
value: key,
})),
}),
)
yield* Effect.orDie(authSvc.remove(provider))
yield* Prompt.outro("Logout successful")
}),
})
Original file line number Diff line number Diff line change
Expand Up @@ -149,9 +149,9 @@ exports[`opencode CLI help-text snapshots every documented command emits stable
manage AI providers and credentials

Commands:
opencode providers list list providers and credentials [aliases: ls]
opencode providers login [url] log in to a provider
opencode providers logout log out from a configured provider
opencode providers list list providers and credentials [aliases: ls]
opencode providers login [url] log in to a provider
opencode providers logout [provider] log out from a configured provider

Options:
-h, --help show help [boolean]
Expand Down Expand Up @@ -504,10 +504,13 @@ Options:
`;

exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode providers logout --help 1`] = `
"opencode providers logout
"opencode providers logout [provider]

log out from a configured provider

Positionals:
provider provider id or name to log out from (skips provider selection) [string]

Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
Expand Down
Loading