opencode/packages/core/test/session-runner-model.test.ts

449 lines
16 KiB
TypeScript

import { describe, expect } from "bun:test"
import { LLM } from "@opencode-ai/llm"
import { LLMClient } from "@opencode-ai/llm/route"
import { DateTime, Effect } from "effect"
import { Headers } from "effect/unstable/http"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ProjectV2 } from "@opencode-ai/core/project"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionV2 } from "@opencode-ai/core/session"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { it } from "./lib/effect"
type Api =
| {
readonly type: "aisdk"
readonly package: string
readonly url?: string
readonly settings?: Record<string, unknown>
}
| { readonly type: "native"; readonly url?: string; readonly settings: Record<string, unknown> }
const model = (api: Api, variants: ModelV2.Info["variants"] = []) =>
ModelV2.Info.make({
id: ModelV2.ID.make("test-model"),
providerID: ProviderV2.ID.make("test-provider"),
name: "Test model",
api: { id: ModelV2.ID.make("api-test-model"), ...api },
capabilities: { tools: true, input: ["text"], output: ["text"] },
request: {
settings: {},
headers: { "x-test": "header" },
body: { apiKey: "secret", custom_extension: { enabled: true } },
},
variants,
time: { released: 0 },
cost: [],
status: "active",
enabled: true,
limit: { context: 100, output: 20 },
})
describe("SessionRunnerModel", () => {
it.effect("maps catalog OpenAI AI SDK models into native Responses routes", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
)
expect(resolved).toMatchObject({ id: "api-test-model", provider: "test-provider" })
expect(resolved.route).toMatchObject({
id: "openai-responses",
endpoint: { baseURL: "https://openai.example/v1" },
defaults: {
headers: { "x-test": "header" },
limits: { context: 100, output: 20 },
http: { body: { custom_extension: { enabled: true } } },
},
})
}),
)
it.effect("keeps catalog apiKey credentials out of provider JSON", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
)
const prepared = yield* LLMClient.prepare(LLM.request({ model: resolved, prompt: "Hello" }))
expect(JSON.stringify(prepared.body)).not.toContain("apiKey")
expect(JSON.stringify(prepared.body)).not.toContain("secret")
}),
)
it.effect("uses merged API settings for OpenAI-compatible auth and request defaults", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
ModelV2.Info.make({
...model({
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://compatible.example/v1",
settings: { apiKey: "settings-secret", compatibility: "strict" },
}),
request: { settings: {}, headers: {}, body: {} },
}),
)
const request = LLM.request({ model: resolved, prompt: "Hello" })
const headers = yield* resolved.route.auth.apply({
request,
method: "POST",
url: "https://compatible.example/v1/chat/completions",
body: "{}",
headers: Headers.empty,
})
expect(headers.authorization).toBe("Bearer settings-secret")
expect(resolved.route.defaults.http?.body).toEqual({})
}),
)
it.effect("overlays selected OpenAI Session variant settings and bodies", () =>
Effect.gen(function* () {
const catalog = model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }, [
{
id: ModelV2.VariantID.make("high"),
settings: { reasoningEffort: "high" },
headers: { "x-variant": "high" },
body: {
store: false,
service_tier: "priority",
temperature: 0.2,
},
},
])
const session = SessionV2.Info.make({
id: SessionV2.ID.make("ses_model_variant"),
projectID: ProjectV2.ID.global,
title: "test",
model: {
id: catalog.id,
providerID: catalog.providerID,
variant: ModelV2.VariantID.make("high"),
},
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: { directory: AbsolutePath.make("/project") },
})
const resolved = yield* SessionRunnerModel.resolve(session, catalog)
expect(resolved.route.defaults.headers).toMatchObject({ "x-test": "header", "x-variant": "high" })
expect(resolved.route.defaults.http?.body).toEqual({
custom_extension: { enabled: true },
store: false,
service_tier: "priority",
temperature: 0.2,
})
expect(resolved.route.defaults.providerOptions).toEqual({
openai: { store: false, reasoningEffort: "high" },
})
}),
)
it.effect("overlays selected OpenAI-compatible Session variant bodies", () =>
Effect.gen(function* () {
const catalog = model(
{ type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://compatible.example/v1" },
[
{
id: ModelV2.VariantID.make("high"),
settings: {},
headers: {},
body: { store: false, reasoning_effort: "high" },
},
],
)
const session = SessionV2.Info.make({
id: SessionV2.ID.make("ses_compatible_variant"),
projectID: ProjectV2.ID.global,
title: "test",
model: { id: catalog.id, providerID: catalog.providerID, variant: ModelV2.VariantID.make("high") },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: { directory: AbsolutePath.make("/project") },
})
const resolved = yield* SessionRunnerModel.resolve(session, catalog)
expect(resolved.route.defaults.http?.body).toEqual({
custom_extension: { enabled: true },
store: false,
reasoning_effort: "high",
})
}),
)
it.effect("rejects an explicit unavailable Session variant during model resolution", () =>
Effect.gen(function* () {
const catalog = model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" })
const session = SessionV2.Info.make({
id: SessionV2.ID.make("ses_model_variant_unavailable"),
projectID: ProjectV2.ID.global,
title: "test",
model: {
id: catalog.id,
providerID: catalog.providerID,
variant: ModelV2.VariantID.make("unknown"),
},
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: { directory: AbsolutePath.make("/project") },
})
const failure = yield* SessionRunnerModel.resolve(session, catalog).pipe(Effect.flip)
expect(failure).toMatchObject({
_tag: "SessionRunnerModel.VariantUnavailableError",
providerID: "test-provider",
modelID: "test-model",
variant: "unknown",
})
expect(failure.message).toBe("Variant unavailable for test-provider/test-model: unknown")
}),
)
it.effect("overlays selected Anthropic Session variant settings", () =>
Effect.gen(function* () {
const catalog = model({ type: "aisdk", package: "@ai-sdk/anthropic", url: "https://anthropic.example/v1" }, [
{
id: ModelV2.VariantID.make("high"),
settings: { thinking: { type: "enabled", budgetTokens: 12000 } },
headers: {},
body: {},
},
])
const session = SessionV2.Info.make({
id: SessionV2.ID.make("ses_anthropic_variant"),
projectID: ProjectV2.ID.global,
title: "test",
model: { id: catalog.id, providerID: catalog.providerID, variant: ModelV2.VariantID.make("high") },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: { directory: AbsolutePath.make("/project") },
})
const resolved = yield* SessionRunnerModel.resolve(session, catalog)
expect(resolved.route.defaults.http?.body).toEqual({
custom_extension: { enabled: true },
})
expect(resolved.route.defaults.providerOptions).toEqual({
anthropic: { thinking: { type: "enabled", budgetTokens: 12000 } },
})
}),
)
it.effect("maps catalog Anthropic AI SDK models into native routes", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
model({ type: "aisdk", package: "@ai-sdk/anthropic", url: "https://anthropic.example/v1" }),
)
expect(resolved.route).toMatchObject({
id: "anthropic-messages",
endpoint: { baseURL: "https://anthropic.example/v1" },
})
}),
)
it.effect("uses resolved credentials for bearer auth", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
ModelV2.Info.make({
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
request: { settings: {}, headers: {}, body: {} },
}),
Credential.Key.make({ type: "key", key: "secret" }),
)
const request = LLM.request({ model: resolved, prompt: "Hello" })
const headers = yield* resolved.route.auth.apply({
request,
method: "POST",
url: "https://openai.example/v1/responses",
body: "{}",
headers: Headers.empty,
})
expect(headers.authorization).toBe("Bearer secret")
}),
)
it.effect("prefers stored credentials over configured auth", () =>
Effect.gen(function* () {
const credential = Credential.Key.make({ type: "key", key: "stored-secret", metadata: { tenant: "work" } })
const resolved = yield* SessionRunnerModel.fromCatalogModel(
ModelV2.Info.make({
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
request: { settings: {}, headers: {}, body: { apiKey: "configured-secret" } },
}),
credential,
)
const headers = yield* resolved.route.auth.apply({
request: LLM.request({ model: resolved, prompt: "Hello" }),
method: "POST",
url: "https://openai.example/v1/responses",
body: "{}",
headers: Headers.empty,
})
expect(headers.authorization).toBe("Bearer stored-secret")
expect(resolved.route.defaults.http?.body).toEqual({ tenant: "work" })
}),
)
it.effect("does not project OAuth account metadata into the request body", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
ModelV2.Info.make({
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
request: { settings: {}, headers: {}, body: {} },
}),
Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("device"),
access: "secret",
refresh: "refresh",
expires: Date.now() + 60_000,
metadata: { server: "https://console.example", orgID: "org_123" },
}),
)
expect(resolved.route.defaults.http?.body).toEqual({})
}),
)
it.effect("routes ChatGPT OAuth credentials to the codex backend", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
ModelV2.Info.make({
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
request: { headers: {}, body: {} },
}),
Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("chatgpt-browser"),
access: "chatgpt-token",
refresh: "refresh",
expires: Date.now() + 60_000,
metadata: { accountID: "acct_123" },
}),
)
const request = LLM.request({ model: resolved, prompt: "Hello" })
const headers = yield* resolved.route.auth.apply({
request,
method: "POST",
url: "https://chatgpt.com/backend-api/codex/responses",
body: "{}",
headers: Headers.empty,
})
expect(resolved.route).toMatchObject({
id: "openai-responses",
endpoint: { baseURL: "https://chatgpt.com/backend-api/codex" },
})
expect(headers.authorization).toBe("Bearer chatgpt-token")
expect(headers["chatgpt-account-id"]).toBe("acct_123")
}),
)
it.effect("routes ChatGPT OAuth credentials without an account id to the codex backend", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
ModelV2.Info.make({
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
request: { headers: {}, body: {} },
}),
Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("chatgpt-headless"),
access: "chatgpt-token",
refresh: "refresh",
expires: Date.now() + 60_000,
}),
)
const request = LLM.request({ model: resolved, prompt: "Hello" })
const headers = yield* resolved.route.auth.apply({
request,
method: "POST",
url: "https://chatgpt.com/backend-api/codex/responses",
body: "{}",
headers: Headers.empty,
})
expect(resolved.route.endpoint.baseURL).toBe("https://chatgpt.com/backend-api/codex")
expect(headers.authorization).toBe("Bearer chatgpt-token")
expect(headers["chatgpt-account-id"]).toBeUndefined()
}),
)
it.effect("keeps non-ChatGPT OAuth credentials on the configured endpoint", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
ModelV2.Info.make({
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
request: { headers: {}, body: {} },
}),
Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("device"),
access: "oauth-token",
refresh: "refresh",
expires: Date.now() + 60_000,
metadata: { accountID: "acct_123" },
}),
)
const request = LLM.request({ model: resolved, prompt: "Hello" })
const headers = yield* resolved.route.auth.apply({
request,
method: "POST",
url: "https://openai.example/v1/responses",
body: "{}",
headers: Headers.empty,
})
expect(resolved.route.endpoint.baseURL).toBe("https://openai.example/v1")
expect(headers.authorization).toBe("Bearer oauth-token")
expect(headers["chatgpt-account-id"]).toBeUndefined()
}),
)
it.effect("rejects catalog APIs without a native route", () =>
Effect.gen(function* () {
const failure = yield* SessionRunnerModel.fromCatalogModel(
model({ type: "aisdk", package: "@ai-sdk/google", url: "https://google.example/v1" }),
).pipe(Effect.flip)
expect(failure).toMatchObject({
_tag: "SessionRunnerModel.UnsupportedApiError",
providerID: "test-provider",
modelID: "test-model",
api: "aisdk:@ai-sdk/google",
})
expect(failure.message).toBe("Unsupported API for test-provider/test-model: aisdk:@ai-sdk/google")
}),
)
it.effect("reports whether a catalog model has a supported native route", () =>
Effect.sync(() => {
expect(
SessionRunnerModel.supported(
model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
),
).toBe(true)
expect(
SessionRunnerModel.supported(
model({ type: "aisdk", package: "@ai-sdk/google", url: "https://google.example/v1" }),
),
).toBe(false)
expect(SessionRunnerModel.supported(model({ type: "native", settings: {} }))).toBe(false)
}),
)
})