session-runner-model.test.ts 25 KB

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