session-runner-model.test.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689
  1. import { describe, expect } from "bun:test"
  2. import { LLM, Model } from "@opencode-ai/ai"
  3. import { LLMClient } from "@opencode-ai/ai/route"
  4. import { DateTime, Effect } from "effect"
  5. import { Money } from "@opencode-ai/schema/money"
  6. import { Headers } from "effect/unstable/http"
  7. import { Credential } from "@opencode-ai/core/credential"
  8. import { Integration } from "@opencode-ai/core/integration"
  9. import { ModelV2 } from "@opencode-ai/core/model"
  10. import { ProviderV2 } from "@opencode-ai/core/provider"
  11. import { ProjectV2 } from "@opencode-ai/core/project"
  12. import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
  13. import { SessionV2 } from "@opencode-ai/core/session"
  14. import { AbsolutePath } from "@opencode-ai/core/schema"
  15. import { it } from "./lib/effect"
  16. interface ModelOptions {
  17. readonly modelID?: string
  18. readonly settings?: ModelV2.Info["settings"]
  19. readonly headers?: ModelV2.Info["headers"]
  20. readonly body?: ModelV2.Info["body"]
  21. readonly variants?: ModelV2.Info["variants"]
  22. }
  23. const model = (packageName: string | undefined, options: ModelOptions = {}) =>
  24. ModelV2.Info.make({
  25. id: ModelV2.ID.make("test-model"),
  26. modelID: ModelV2.ID.make(options.modelID ?? "api-test-model"),
  27. providerID: ProviderV2.ID.make("test-provider"),
  28. name: "Test model",
  29. package: packageName,
  30. settings: options.settings ?? {},
  31. headers: options.headers ?? { "x-test": "header" },
  32. body: options.body ?? { custom_extension: { enabled: true } },
  33. capabilities: { tools: true, input: ["text"], output: ["text"] },
  34. variants: options.variants ?? [],
  35. time: { released: 0 },
  36. cost: [],
  37. status: "active",
  38. enabled: true,
  39. limit: { context: 100, output: 20 },
  40. })
  41. describe("SessionRunnerModel", () => {
  42. it.effect("uses the API modelID instead of the catalog ID for native OpenAI routes", () =>
  43. Effect.gen(function* () {
  44. const catalog = model(ProviderV2.aisdk("@ai-sdk/openai"), {
  45. settings: { baseURL: "https://openai.example/v1" },
  46. })
  47. const resolved = yield* SessionRunnerModel.fromCatalogModel(catalog)
  48. expect(catalog.id).toBe(ModelV2.ID.make("test-model"))
  49. expect(resolved).toMatchObject({ id: "api-test-model", provider: "test-provider" })
  50. expect(resolved.route).toMatchObject({
  51. id: "openai-responses",
  52. providerMetadataKey: "openai",
  53. endpoint: { baseURL: "https://openai.example/v1" },
  54. defaults: {
  55. headers: { "x-test": "header" },
  56. limits: { context: 100, output: 20 },
  57. http: { body: { custom_extension: { enabled: true } } },
  58. },
  59. })
  60. }),
  61. )
  62. it.effect("keeps catalog apiKey credentials out of provider JSON", () =>
  63. Effect.gen(function* () {
  64. const resolved = yield* SessionRunnerModel.fromCatalogModel(
  65. model(ProviderV2.aisdk("@ai-sdk/openai"), {
  66. settings: { apiKey: "secret", baseURL: "https://openai.example/v1" },
  67. }),
  68. )
  69. const prepared = yield* LLMClient.prepare(LLM.request({ model: resolved, prompt: "Hello" }))
  70. expect(JSON.stringify(prepared.body)).not.toContain("apiKey")
  71. expect(JSON.stringify(prepared.body)).not.toContain("secret")
  72. }),
  73. )
  74. it.effect("treats an empty configured API key as omitted", () =>
  75. Effect.gen(function* () {
  76. const resolved = yield* SessionRunnerModel.fromCatalogModel(
  77. model(ProviderV2.aisdk("@ai-sdk/openai"), {
  78. settings: { apiKey: "", baseURL: "https://openai.example/v1" },
  79. }),
  80. )
  81. const headers = yield* resolved.route.auth.apply({
  82. request: LLM.request({ model: resolved, prompt: "Hello" }),
  83. method: "POST",
  84. url: "https://openai.example/v1/responses",
  85. body: "{}",
  86. headers: Headers.empty,
  87. })
  88. expect(headers.authorization).toBeUndefined()
  89. }),
  90. )
  91. it.effect("uses merged API settings for OpenAI-compatible auth and request defaults", () =>
  92. Effect.gen(function* () {
  93. const resolved = yield* SessionRunnerModel.fromCatalogModel(
  94. model(ProviderV2.aisdk("@ai-sdk/openai-compatible"), {
  95. settings: {
  96. apiKey: "settings-secret",
  97. baseURL: "https://compatible.example/v1",
  98. compatibility: "strict",
  99. },
  100. headers: {},
  101. body: {},
  102. }),
  103. )
  104. const request = LLM.request({ model: resolved, prompt: "Hello" })
  105. const headers = yield* resolved.route.auth.apply({
  106. request,
  107. method: "POST",
  108. url: "https://compatible.example/v1/chat/completions",
  109. body: "{}",
  110. headers: Headers.empty,
  111. })
  112. expect(headers.authorization).toBe("Bearer settings-secret")
  113. expect(resolved.route.id).toBe("openai-compatible-chat")
  114. expect(resolved.route.endpoint.baseURL).toBe("https://compatible.example/v1")
  115. expect(resolved.route.defaults.http?.body).toEqual({})
  116. }),
  117. )
  118. it.effect("overlays selected OpenAI Session variant settings and bodies", () =>
  119. Effect.gen(function* () {
  120. const catalog = model(ProviderV2.aisdk("@ai-sdk/openai"), {
  121. settings: { baseURL: "https://openai.example/v1" },
  122. variants: [
  123. {
  124. id: ModelV2.VariantID.make("high"),
  125. settings: { reasoningEffort: "high" },
  126. headers: { "x-variant": "high" },
  127. body: {
  128. store: false,
  129. service_tier: "priority",
  130. temperature: 0.2,
  131. },
  132. },
  133. ],
  134. })
  135. const session = SessionV2.Info.make({
  136. id: SessionV2.ID.make("ses_model_variant"),
  137. projectID: ProjectV2.ID.global,
  138. title: "test",
  139. model: {
  140. id: catalog.id,
  141. providerID: catalog.providerID,
  142. variant: ModelV2.VariantID.make("high"),
  143. },
  144. cost: Money.USD.zero,
  145. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  146. time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
  147. location: { directory: AbsolutePath.make("/project") },
  148. })
  149. const resolved = yield* SessionRunnerModel.resolve(session, catalog)
  150. expect(resolved.route.defaults.headers).toMatchObject({ "x-test": "header", "x-variant": "high" })
  151. expect(resolved.route.defaults.http?.body).toEqual({
  152. custom_extension: { enabled: true },
  153. store: false,
  154. service_tier: "priority",
  155. temperature: 0.2,
  156. })
  157. expect(resolved.route.defaults.providerOptions).toEqual({
  158. openai: { store: false, reasoningEffort: "high" },
  159. })
  160. }),
  161. )
  162. it.effect("overlays selected OpenAI-compatible Session variant bodies", () =>
  163. Effect.gen(function* () {
  164. const catalog = model(ProviderV2.aisdk("@ai-sdk/openai-compatible"), {
  165. settings: { baseURL: "https://compatible.example/v1" },
  166. variants: [
  167. {
  168. id: ModelV2.VariantID.make("high"),
  169. settings: {},
  170. headers: {},
  171. body: { store: false, reasoning_effort: "high" },
  172. },
  173. ],
  174. })
  175. const session = SessionV2.Info.make({
  176. id: SessionV2.ID.make("ses_compatible_variant"),
  177. projectID: ProjectV2.ID.global,
  178. title: "test",
  179. model: { id: catalog.id, providerID: catalog.providerID, variant: ModelV2.VariantID.make("high") },
  180. cost: Money.USD.zero,
  181. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  182. time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
  183. location: { directory: AbsolutePath.make("/project") },
  184. })
  185. const resolved = yield* SessionRunnerModel.resolve(session, catalog)
  186. expect(resolved.route.defaults.http?.body).toEqual({
  187. custom_extension: { enabled: true },
  188. store: false,
  189. reasoning_effort: "high",
  190. })
  191. }),
  192. )
  193. it.effect("rejects an explicit unavailable Session variant during model resolution", () =>
  194. Effect.gen(function* () {
  195. const catalog = model(ProviderV2.aisdk("@ai-sdk/openai"), {
  196. settings: { baseURL: "https://openai.example/v1" },
  197. })
  198. const session = SessionV2.Info.make({
  199. id: SessionV2.ID.make("ses_model_variant_unavailable"),
  200. projectID: ProjectV2.ID.global,
  201. title: "test",
  202. model: {
  203. id: catalog.id,
  204. providerID: catalog.providerID,
  205. variant: ModelV2.VariantID.make("unknown"),
  206. },
  207. cost: Money.USD.zero,
  208. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  209. time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
  210. location: { directory: AbsolutePath.make("/project") },
  211. })
  212. const failure = yield* SessionRunnerModel.resolve(session, catalog).pipe(Effect.flip)
  213. expect(failure).toMatchObject({
  214. _tag: "SessionRunnerModel.VariantUnavailableError",
  215. providerID: "test-provider",
  216. modelID: "test-model",
  217. variant: "unknown",
  218. })
  219. expect(failure.message).toBe("Variant unavailable for test-provider/test-model: unknown")
  220. }),
  221. )
  222. it.effect("overlays selected Anthropic Session variant settings", () =>
  223. Effect.gen(function* () {
  224. const catalog = model(ProviderV2.aisdk("@ai-sdk/anthropic"), {
  225. settings: { baseURL: "https://anthropic.example/v1" },
  226. variants: [
  227. {
  228. id: ModelV2.VariantID.make("high"),
  229. settings: { thinking: { type: "enabled", budgetTokens: 12000 } },
  230. headers: {},
  231. body: {},
  232. },
  233. ],
  234. })
  235. const session = SessionV2.Info.make({
  236. id: SessionV2.ID.make("ses_anthropic_variant"),
  237. projectID: ProjectV2.ID.global,
  238. title: "test",
  239. model: { id: catalog.id, providerID: catalog.providerID, variant: ModelV2.VariantID.make("high") },
  240. cost: Money.USD.zero,
  241. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  242. time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
  243. location: { directory: AbsolutePath.make("/project") },
  244. })
  245. const resolved = yield* SessionRunnerModel.resolve(session, catalog)
  246. expect(resolved.route.defaults.http?.body).toEqual({
  247. custom_extension: { enabled: true },
  248. })
  249. expect(resolved.route.defaults.providerOptions).toEqual({
  250. anthropic: { thinking: { type: "enabled", budgetTokens: 12000 } },
  251. })
  252. }),
  253. )
  254. it.effect("maps catalog Anthropic AI SDK models into native routes", () =>
  255. Effect.gen(function* () {
  256. const resolved = yield* SessionRunnerModel.fromCatalogModel(
  257. model(ProviderV2.aisdk("@ai-sdk/anthropic"), {
  258. settings: { baseURL: "https://anthropic.example/v1" },
  259. }),
  260. )
  261. expect(resolved.route).toMatchObject({
  262. id: "anthropic-messages",
  263. providerMetadataKey: "anthropic",
  264. endpoint: { baseURL: "https://anthropic.example/v1" },
  265. })
  266. }),
  267. )
  268. it.effect("uses resolved credentials for bearer auth", () =>
  269. Effect.gen(function* () {
  270. const resolved = yield* SessionRunnerModel.fromCatalogModel(
  271. model(ProviderV2.aisdk("@ai-sdk/openai"), {
  272. settings: { baseURL: "https://openai.example/v1" },
  273. headers: {},
  274. body: {},
  275. }),
  276. Credential.Key.make({ type: "key", key: "secret" }),
  277. )
  278. const request = LLM.request({ model: resolved, prompt: "Hello" })
  279. const headers = yield* resolved.route.auth.apply({
  280. request,
  281. method: "POST",
  282. url: "https://openai.example/v1/responses",
  283. body: "{}",
  284. headers: Headers.empty,
  285. })
  286. expect(headers.authorization).toBe("Bearer secret")
  287. }),
  288. )
  289. it.effect("prefers stored credentials over configured auth", () =>
  290. Effect.gen(function* () {
  291. const credential = Credential.Key.make({ type: "key", key: "stored-secret", metadata: { tenant: "work" } })
  292. const resolved = yield* SessionRunnerModel.fromCatalogModel(
  293. model(ProviderV2.aisdk("@ai-sdk/openai"), {
  294. settings: { apiKey: "configured-secret", baseURL: "https://openai.example/v1" },
  295. headers: {},
  296. body: {},
  297. }),
  298. credential,
  299. )
  300. const headers = yield* resolved.route.auth.apply({
  301. request: LLM.request({ model: resolved, prompt: "Hello" }),
  302. method: "POST",
  303. url: "https://openai.example/v1/responses",
  304. body: "{}",
  305. headers: Headers.empty,
  306. })
  307. expect(headers.authorization).toBe("Bearer stored-secret")
  308. expect(resolved.route.defaults.http?.body).toEqual({ tenant: "work" })
  309. }),
  310. )
  311. it.effect("does not project OAuth account metadata into the request body", () =>
  312. Effect.gen(function* () {
  313. const resolved = yield* SessionRunnerModel.fromCatalogModel(
  314. model(ProviderV2.aisdk("@ai-sdk/openai"), {
  315. settings: { baseURL: "https://openai.example/v1" },
  316. headers: {},
  317. body: {},
  318. }),
  319. Credential.OAuth.make({
  320. type: "oauth",
  321. methodID: Integration.MethodID.make("device"),
  322. access: "secret",
  323. refresh: "refresh",
  324. expires: Date.now() + 60_000,
  325. metadata: { server: "https://console.example", orgID: "org_123" },
  326. }),
  327. )
  328. expect(resolved.route.defaults.http?.body).toEqual({})
  329. }),
  330. )
  331. it.effect("routes ChatGPT OAuth credentials to the codex backend", () =>
  332. Effect.gen(function* () {
  333. const resolved = yield* SessionRunnerModel.fromCatalogModel(
  334. model(ProviderV2.aisdk("@ai-sdk/openai"), {
  335. settings: { baseURL: "https://openai.example/v1" },
  336. headers: {},
  337. body: {},
  338. }),
  339. Credential.OAuth.make({
  340. type: "oauth",
  341. methodID: Integration.MethodID.make("chatgpt-browser"),
  342. access: "chatgpt-token",
  343. refresh: "refresh",
  344. expires: Date.now() + 60_000,
  345. metadata: { accountID: "acct_123" },
  346. }),
  347. )
  348. const request = LLM.request({ model: resolved, prompt: "Hello" })
  349. const headers = yield* resolved.route.auth.apply({
  350. request,
  351. method: "POST",
  352. url: "https://chatgpt.com/backend-api/codex/responses",
  353. body: "{}",
  354. headers: Headers.empty,
  355. })
  356. expect(resolved.route).toMatchObject({
  357. id: "openai-responses",
  358. endpoint: { baseURL: "https://chatgpt.com/backend-api/codex" },
  359. })
  360. expect(headers.authorization).toBe("Bearer chatgpt-token")
  361. expect(headers["chatgpt-account-id"]).toBe("acct_123")
  362. }),
  363. )
  364. it.effect("routes native OpenAI provider packages with ChatGPT credentials to the codex backend", () =>
  365. Effect.gen(function* () {
  366. const resolved = yield* SessionRunnerModel.fromCatalogModel(
  367. model("@opencode-ai/ai/providers/openai", {
  368. settings: { baseURL: "https://openai.example/v1" },
  369. }),
  370. Credential.OAuth.make({
  371. type: "oauth",
  372. methodID: Integration.MethodID.make("chatgpt-browser"),
  373. access: "chatgpt-token",
  374. refresh: "refresh",
  375. expires: Date.now() + 60_000,
  376. metadata: { accountID: "acct_123" },
  377. }),
  378. )
  379. const headers = yield* resolved.route.auth.apply({
  380. request: LLM.request({ model: resolved, prompt: "Hello" }),
  381. method: "POST",
  382. url: "https://chatgpt.com/backend-api/codex/responses",
  383. body: "{}",
  384. headers: Headers.empty,
  385. })
  386. expect(resolved.route.endpoint.baseURL).toBe("https://chatgpt.com/backend-api/codex")
  387. expect(headers.authorization).toBe("Bearer chatgpt-token")
  388. expect(headers["chatgpt-account-id"]).toBe("acct_123")
  389. }),
  390. )
  391. it.effect("does not route native OpenAI-compatible packages to the codex backend", () =>
  392. Effect.gen(function* () {
  393. const resolved = yield* SessionRunnerModel.fromCatalogModel(
  394. model("@opencode-ai/ai/providers/openai-compatible", {
  395. settings: { baseURL: "https://compatible.example/v1" },
  396. }),
  397. Credential.OAuth.make({
  398. type: "oauth",
  399. methodID: Integration.MethodID.make("chatgpt-browser"),
  400. access: "chatgpt-token",
  401. refresh: "refresh",
  402. expires: Date.now() + 60_000,
  403. metadata: { accountID: "acct_123" },
  404. }),
  405. )
  406. expect(resolved.route.id).toBe("openai-compatible-chat")
  407. expect(resolved.route.endpoint.baseURL).toBe("https://compatible.example/v1")
  408. }),
  409. )
  410. it.effect("maps legacy OpenAI organization and project settings to headers", () =>
  411. Effect.gen(function* () {
  412. const resolved = yield* SessionRunnerModel.fromCatalogModel(
  413. model(ProviderV2.aisdk("@ai-sdk/openai"), {
  414. settings: { organization: "org_123", project: "proj_123" },
  415. }),
  416. )
  417. expect(resolved.route.defaults.headers).toMatchObject({
  418. "OpenAI-Organization": "org_123",
  419. "OpenAI-Project": "proj_123",
  420. })
  421. }),
  422. )
  423. it.effect("routes ChatGPT OAuth credentials without an account id to the codex backend", () =>
  424. Effect.gen(function* () {
  425. const resolved = yield* SessionRunnerModel.fromCatalogModel(
  426. model(ProviderV2.aisdk("@ai-sdk/openai"), {
  427. settings: { baseURL: "https://openai.example/v1" },
  428. headers: {},
  429. body: {},
  430. }),
  431. Credential.OAuth.make({
  432. type: "oauth",
  433. methodID: Integration.MethodID.make("chatgpt-headless"),
  434. access: "chatgpt-token",
  435. refresh: "refresh",
  436. expires: Date.now() + 60_000,
  437. }),
  438. )
  439. const request = LLM.request({ model: resolved, prompt: "Hello" })
  440. const headers = yield* resolved.route.auth.apply({
  441. request,
  442. method: "POST",
  443. url: "https://chatgpt.com/backend-api/codex/responses",
  444. body: "{}",
  445. headers: Headers.empty,
  446. })
  447. expect(resolved.route.endpoint.baseURL).toBe("https://chatgpt.com/backend-api/codex")
  448. expect(headers.authorization).toBe("Bearer chatgpt-token")
  449. expect(headers["chatgpt-account-id"]).toBeUndefined()
  450. }),
  451. )
  452. it.effect("keeps non-ChatGPT OAuth credentials on the configured endpoint", () =>
  453. Effect.gen(function* () {
  454. const resolved = yield* SessionRunnerModel.fromCatalogModel(
  455. model(ProviderV2.aisdk("@ai-sdk/openai"), {
  456. settings: { baseURL: "https://openai.example/v1" },
  457. headers: {},
  458. body: {},
  459. }),
  460. Credential.OAuth.make({
  461. type: "oauth",
  462. methodID: Integration.MethodID.make("device"),
  463. access: "oauth-token",
  464. refresh: "refresh",
  465. expires: Date.now() + 60_000,
  466. metadata: { accountID: "acct_123" },
  467. }),
  468. )
  469. const request = LLM.request({ model: resolved, prompt: "Hello" })
  470. const headers = yield* resolved.route.auth.apply({
  471. request,
  472. method: "POST",
  473. url: "https://openai.example/v1/responses",
  474. body: "{}",
  475. headers: Headers.empty,
  476. })
  477. expect(resolved.route.endpoint.baseURL).toBe("https://openai.example/v1")
  478. expect(headers.authorization).toBe("Bearer oauth-token")
  479. expect(headers["chatgpt-account-id"]).toBeUndefined()
  480. }),
  481. )
  482. it.effect("loads dynamic native provider packages through the injected package loader", () =>
  483. Effect.gen(function* () {
  484. const native = yield* SessionRunnerModel.fromCatalogModel(
  485. model(ProviderV2.aisdk("@ai-sdk/openai"), {
  486. settings: { baseURL: "https://openai.example/v1" },
  487. }),
  488. )
  489. const resolved = yield* SessionRunnerModel.fromCatalogModel(
  490. model("@opencode-ai/ai/providers/custom", {
  491. settings: { region: "test" },
  492. headers: { "x-package": "header" },
  493. body: { custom: true },
  494. }),
  495. undefined,
  496. {
  497. loadPackage: (specifier) => {
  498. expect(specifier).toBe("@opencode-ai/ai/providers/custom")
  499. return Effect.succeed({
  500. model: (modelID, settings) => {
  501. expect(modelID).toBe("api-test-model")
  502. expect(settings).toEqual({
  503. region: "test",
  504. headers: { "x-package": "header" },
  505. body: { custom: true },
  506. limits: { context: 100, output: 20 },
  507. })
  508. return Model.make({ id: modelID, provider: "package-provider", route: native.route })
  509. },
  510. })
  511. },
  512. },
  513. )
  514. expect(resolved).toMatchObject({ id: "api-test-model", provider: "test-provider" })
  515. }),
  516. )
  517. it.effect("maps OAuth credentials to native provider auth settings", () =>
  518. Effect.gen(function* () {
  519. const native = yield* SessionRunnerModel.fromCatalogModel(
  520. model(ProviderV2.aisdk("@ai-sdk/openai"), {
  521. settings: { baseURL: "https://openai.example/v1" },
  522. }),
  523. )
  524. const credential = Credential.OAuth.make({
  525. type: "oauth",
  526. methodID: Integration.MethodID.make("device"),
  527. access: "oauth-token",
  528. refresh: "refresh",
  529. expires: Date.now() + 60_000,
  530. })
  531. const packages = [
  532. ["@opencode-ai/ai/providers/google-vertex", "accessToken"],
  533. ["@opencode-ai/ai/providers/google-vertex/gemini", "accessToken"],
  534. ["@opencode-ai/ai/providers/google-vertex/chat", "accessToken"],
  535. ["@opencode-ai/ai/providers/google-vertex/responses", "accessToken"],
  536. ["@opencode-ai/ai/providers/google-vertex/messages", "accessToken"],
  537. ["@opencode-ai/ai/providers/anthropic", "authToken"],
  538. ["@opencode-ai/ai/providers/anthropic-compatible", "authToken"],
  539. ] as const
  540. yield* Effect.forEach(packages, ([specifier, key]) =>
  541. SessionRunnerModel.fromCatalogModel(model(specifier, { settings: { apiKey: "configured-key" } }), credential, {
  542. loadPackage: () =>
  543. Effect.succeed({
  544. model: (modelID, settings) => {
  545. expect(settings).toMatchObject({ [key]: "oauth-token" })
  546. expect(settings).not.toHaveProperty("apiKey")
  547. return Model.make({ id: modelID, provider: "package-provider", route: native.route })
  548. },
  549. }),
  550. }),
  551. )
  552. }),
  553. )
  554. it.effect("loads arbitrary AISDK packages through the injected AISDK loader", () =>
  555. Effect.gen(function* () {
  556. const native = yield* SessionRunnerModel.fromCatalogModel(
  557. model(ProviderV2.aisdk("@ai-sdk/openai"), {
  558. settings: { baseURL: "https://openai.example/v1" },
  559. }),
  560. )
  561. const resolved = yield* SessionRunnerModel.fromCatalogModel(
  562. model(ProviderV2.aisdk("@ai-sdk/google"), {
  563. modelID: "gemini-api-model",
  564. settings: { project: "test" },
  565. headers: { "x-aisdk": "header" },
  566. body: { custom: true },
  567. }),
  568. Credential.Key.make({ type: "key", key: "fallback-secret" }),
  569. {
  570. loadAISDK: (runtime) =>
  571. Effect.sync(() => {
  572. expect(runtime).toMatchObject({
  573. id: "test-model",
  574. modelID: "gemini-api-model",
  575. providerID: "test-provider",
  576. package: ProviderV2.aisdk("@ai-sdk/google"),
  577. settings: { project: "test", apiKey: "fallback-secret" },
  578. headers: { "x-aisdk": "header" },
  579. body: { custom: true },
  580. })
  581. return Model.make({
  582. id: runtime.modelID ?? runtime.id,
  583. provider: runtime.providerID,
  584. route: native.route,
  585. })
  586. }),
  587. },
  588. )
  589. expect(resolved).toMatchObject({ id: "gemini-api-model", provider: "test-provider" })
  590. }),
  591. )
  592. it.effect("rejects AISDK packages without an available loader", () =>
  593. Effect.gen(function* () {
  594. const failure = yield* SessionRunnerModel.fromCatalogModel(
  595. model(ProviderV2.aisdk("@ai-sdk/google"), {
  596. settings: { baseURL: "https://google.example/v1" },
  597. }),
  598. ).pipe(Effect.flip)
  599. expect(failure).toMatchObject({
  600. _tag: "SessionRunnerModel.UnsupportedPackageError",
  601. providerID: "test-provider",
  602. modelID: "test-model",
  603. package: "aisdk:@ai-sdk/google",
  604. })
  605. expect(failure.message).toBe("Unsupported package for test-provider/test-model: aisdk:@ai-sdk/google")
  606. }),
  607. )
  608. it.effect("drops an empty API key before loading an AISDK package", () =>
  609. Effect.gen(function* () {
  610. const native = yield* SessionRunnerModel.fromCatalogModel(
  611. model(ProviderV2.aisdk("@ai-sdk/openai"), {
  612. settings: { baseURL: "https://openai.example/v1" },
  613. }),
  614. )
  615. yield* SessionRunnerModel.fromCatalogModel(
  616. model(ProviderV2.aisdk("@ai-sdk/google"), {
  617. settings: { apiKey: "", baseURL: "https://google.example/v1" },
  618. }),
  619. undefined,
  620. {
  621. loadAISDK: (runtime) =>
  622. Effect.sync(() => {
  623. expect(runtime.settings).not.toHaveProperty("apiKey")
  624. return native
  625. }),
  626. },
  627. )
  628. }),
  629. )
  630. it.effect("reports whether a catalog model declares a provider package", () =>
  631. Effect.sync(() => {
  632. expect(SessionRunnerModel.supported(model(ProviderV2.aisdk("@ai-sdk/openai")))).toBe(true)
  633. expect(SessionRunnerModel.supported(model("@opencode-ai/ai/providers/custom"))).toBe(true)
  634. expect(SessionRunnerModel.supported(model(undefined))).toBe(false)
  635. }),
  636. )
  637. })