Compare commits

...

2 Commits

Author SHA1 Message Date
Kit Langton 85f03b2712 fix(tui): clarify working copy actions 2026-08-06 19:56:40 -04:00
Aiden Cline dcae95e2bb feat(ai): expose model compatibility options (#40942) 2026-08-06 17:46:05 -05:00
9 changed files with 67 additions and 9 deletions
+1
View File
@@ -178,6 +178,7 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
toolSchema: Schema.optional(LanguageModelToolSchemaCompatibility),
reasoningField: Schema.optional(Schema.String),
maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility),
requireFinishReason: Schema.optional(Schema.Boolean),
}) {}
export namespace LanguageModelCompatibility {
+2 -2
View File
@@ -102,7 +102,7 @@ describe("llm constructors", () => {
const updated = LanguageModel.update(base, {
route: responsesRoute,
defaults: { generation: { maxTokens: 20 } },
compatibility: { toolSchema: "gemini" },
compatibility: { toolSchema: "gemini", requireFinishReason: false },
})
const updatedInput = LanguageModel.input(updated)
@@ -110,7 +110,7 @@ describe("llm constructors", () => {
expect(String(updated.id)).toBe("fake-model")
expect(updated.route).toBe(responsesRoute)
expect(updated.defaults?.generation).toEqual({ maxTokens: 20 })
expect(updated.compatibility).toEqual({ toolSchema: "gemini" })
expect(updated.compatibility).toEqual({ toolSchema: "gemini", requireFinishReason: false })
expect(updatedInput.defaults).toBe(updated.defaults)
expect(updatedInput.compatibility).toBe(updated.compatibility)
expect(String(updatedInput.provider)).toBe("fake")
@@ -169,6 +169,8 @@ export type EventLogSynced = { type: "log.synced"; aggregateID: string; seq?: nu
export type ModelReasoningField = "reasoning" | "reasoning_content" | "reasoning_text" | (string & {})
export type ModelMaxTokensField = "max_completion_tokens" | "max_tokens"
export type ModelCapabilities = { tools: boolean; input: Array<string>; output: Array<string> }
export type ModelVariant = {
@@ -1230,7 +1232,11 @@ export type SessionToolCalled = {
export type ToolContent1 = ToolTextContent | ToolFileContent1
export type ModelCompatibility = { reasoningField?: ModelReasoningField }
export type ModelCompatibility = {
reasoningField?: ModelReasoningField
maxTokensField?: ModelMaxTokensField
requireFinishReason?: boolean
}
export type ModelCost = {
tier?: { type: "context"; size: number }
+10 -2
View File
@@ -237,7 +237,11 @@ describe("ConfigProviderPlugin.Plugin", () => {
models: {
chat: {
name: "First",
compatibility: { reasoningField: "vendor_reasoning" },
compatibility: {
reasoningField: "vendor_reasoning",
maxTokensField: "max_completion_tokens",
requireFinishReason: false,
},
capabilities: { tools: true, input: ["text"], output: ["text"] },
disabled: true,
limit: { context: 100, output: 50 },
@@ -318,7 +322,11 @@ describe("ConfigProviderPlugin.Plugin", () => {
expect(model.id).toBe(modelID)
expect(model.modelID).toBe(Model.ID.make("api-chat"))
expect(model.name).toBe("Last")
expect(model.compatibility).toEqual({ reasoningField: "vendor_reasoning" })
expect(model.compatibility).toEqual({
reasoningField: "vendor_reasoning",
maxTokensField: "max_completion_tokens",
requireFinishReason: false,
})
expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
expect(model.enabled).toBe(false)
expect(model.limit).toEqual({ context: 100, output: 75 })
+11 -2
View File
@@ -194,7 +194,11 @@ describe("ModelResolver", () => {
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/openai-compatible"), {
compatibility: { reasoningField: "vendor_reasoning" },
compatibility: {
reasoningField: "vendor_reasoning",
maxTokensField: "max_completion_tokens",
requireFinishReason: false,
},
settings: {
apiKey: "settings-secret",
baseURL: "https://compatible.example/v1",
@@ -204,7 +208,8 @@ describe("ModelResolver", () => {
body: {},
}),
)
const request = LLM.request({ model: resolved, prompt: "Hello" })
const request = LLM.request({ model: resolved, prompt: "Hello", generation: { maxTokens: 10 } })
const prepared = yield* compileRequest(request)
const headers = yield* resolved.route.auth.apply({
request,
method: "POST",
@@ -216,6 +221,10 @@ describe("ModelResolver", () => {
expect(headers.authorization).toBe("Bearer settings-secret")
expect(resolved.route.id).toBe("openai-compatible-chat")
expect(resolved.compatibility?.reasoningField).toBe("vendor_reasoning")
expect(resolved.compatibility?.maxTokensField).toBe("max_completion_tokens")
expect(resolved.compatibility?.requireFinishReason).toBe(false)
expect(prepared.body).toMatchObject({ max_completion_tokens: 10 })
expect(prepared.body).not.toHaveProperty("max_tokens")
expect(resolved.route.endpoint.baseURL).toBe("https://compatible.example/v1")
expect(resolved.route.defaults.http?.body).toEqual({})
}),
+7
View File
@@ -47,9 +47,16 @@ export const ReasoningField: Schema.Codec<ReasoningField> = Schema.Union([
Schema.String,
]).annotate({ identifier: "Model.ReasoningField" })
export const MaxTokensField = Schema.Literals(["max_completion_tokens", "max_tokens"]).annotate({
identifier: "Model.MaxTokensField",
})
export type MaxTokensField = typeof MaxTokensField.Type
export interface Compatibility extends Schema.Schema.Type<typeof Compatibility> {}
export const Compatibility = Schema.Struct({
reasoningField: ReasoningField.pipe(optional),
maxTokensField: MaxTokensField.pipe(optional),
requireFinishReason: Schema.Boolean.pipe(optional),
}).annotate({ identifier: "Model.Compatibility" })
export interface Capabilities extends Schema.Schema.Type<typeof Capabilities> {}
+19
View File
@@ -30,3 +30,22 @@ describe("Model.ReasoningField", () => {
expect(decode(field)).toBe(field)
})
})
describe("Model.Compatibility", () => {
test("decodes model compatibility overrides", () => {
const decode = Schema.decodeUnknownSync(Model.Compatibility)
expect(decode({})).toEqual({})
expect(
decode({
reasoningField: "vendor_reasoning",
maxTokensField: "max_completion_tokens",
requireFinishReason: false,
}),
).toEqual({
reasoningField: "vendor_reasoning",
maxTokensField: "max_completion_tokens",
requireFinishReason: false,
})
})
})
@@ -258,6 +258,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
const choice = await DialogWorkspaceFileChanges.show(dialog, status?.data ?? [], {
title: "Delete working copy?",
message: "This working copy has file changes. Do you want to delete it anyway?",
labels: { no: "cancel", yes: "delete" },
})
if (choice !== "yes") {
reopen()
@@ -29,6 +29,7 @@ export function DialogWorkspaceFileChanges(props: {
onSelect: (choice: WorkspaceFileChangesChoice) => void
title?: string
message?: string
labels?: Partial<Record<WorkspaceFileChangesChoice, string>>
}) {
const dialog = useDialog()
const theme = useTheme("elevated")
@@ -125,7 +126,9 @@ export function DialogWorkspaceFileChanges(props: {
dialog.clear()
}}
>
<text fg={item === store.active ? theme.text.action.primary.focused : theme.text.subdued}>{item}</text>
<text fg={item === store.active ? theme.text.action.primary.focused : theme.text.subdued}>
{props.labels?.[item] ?? (item === "no" ? "cancel" : "move")}
</text>
</box>
)}
</For>
@@ -137,7 +140,11 @@ export function DialogWorkspaceFileChanges(props: {
DialogWorkspaceFileChanges.show = (
dialog: DialogContext,
files: VcsFileStatus[],
options?: { title?: string; message?: string },
options?: {
title?: string
message?: string
labels?: Partial<Record<WorkspaceFileChangesChoice, string>>
},
) => {
return new Promise<WorkspaceFileChangesChoice | undefined>((resolve) => {
dialog.replace(