Compare commits

...

155 Commits

Author SHA1 Message Date
Aiden Cline 4086aa8079 fix(core): execute renamed tool definitions 2026-08-02 18:10:40 +00:00
opencode-agent[bot] dd014120cd fix(core): route Bedrock packages natively (#40165)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-02 11:47:35 -05:00
opencode-agent[bot] bf7f0cb521 feat(ai): add native Bedrock Mantle support (#40119)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-02 11:16:22 -05:00
opencode-agent[bot] adbd22833c fix(tui): reserve home and end for navigation (#40123)
Co-authored-by: Simon Klee <hello@simonklee.dk>
2026-08-02 04:39:23 +00:00
Aiden Cline 634b9b21dd refactor(ai): align multimodal naming (#40073) 2026-08-01 21:57:05 -05:00
Kit Langton 5d729521d3 refactor(tui): remove redundant code (#40081) 2026-08-01 14:22:48 -04:00
Kit Langton c391983ae0 fix(tui): discover plugins across config roots (#39988) 2026-08-01 14:06:58 -04:00
Kit Langton f158abd694 fix(tui): make vertical tabs responsive (#40080) 2026-08-01 14:06:13 -04:00
Kit Langton 0e3da4b4f1 feat(tui): add vertical session tabs (#40072) 2026-08-01 13:10:08 -04:00
Aiden Cline 003b22edda fix: preserve provider error status (#39976) 2026-08-01 11:06:00 -05:00
Kit Langton d5f6c088f0 fix(tui): prevent sidebar scrollbar flash (#40056) 2026-08-01 15:12:37 +00:00
Kit Langton e872bd3c8f feat(tui): prioritize favorite model search results (#40049) 2026-08-01 14:54:46 +00:00
opencode-agent[bot] 85ea15e56d test(tui): wait for mini prompt readiness (#39980)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-08-01 01:05:18 +00:00
Kit Langton 6a543791b6 fix(tui): persist tab reorder once per drag (#39942) 2026-07-31 20:36:00 -04:00
Kit Langton 47c6840752 fix(tui): harden session tab state hygiene (#39941) 2026-07-31 20:35:07 -04:00
Kit Langton 14e4c3dfd7 fix(tui): ignore hidden tab close hitbox (#39940) 2026-07-31 20:34:13 -04:00
opencode-agent[bot] ee6577936d refactor(tui): remove unused duration formatter (#39964)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:32:46 -04:00
opencode-agent[bot] 8285a18f18 refactor(tui): remove unused revert diff parser (#39963)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:32:35 -04:00
opencode-agent[bot] cfcd8de62a refactor(cli): remove unused warning helper (#39962)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:32:21 -04:00
opencode-agent[bot] c2db18c59b refactor(tui): remove unwired file selection helper (#39961)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:31:50 -04:00
opencode-agent[bot] 70393a1d18 Remove unused error data serializer (#39960)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:31:22 -04:00
opencode-agent[bot] eab59ea021 Remove unused locale helpers (#39959)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:31:06 -04:00
opencode-agent[bot] 4569659b03 Remove unused Zed helpers (#39958)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:30:53 -04:00
opencode-agent[bot] b5afaed053 Remove unused config optional hook (#39957)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:30:29 -04:00
opencode-agent[bot] 97f5fb3399 Remove unused subagent retry formatter (#39952)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:30:20 -04:00
opencode-agent[bot] 18843682ad refactor(tui): remove ignored attention kv option (#39956)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:30:09 -04:00
opencode-agent[bot] 9eb9c1db7d refactor(tui): remove placeholder LSP panel (#39955)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:29:54 -04:00
opencode-agent[bot] 8846336e11 refactor(tui): remove unused fade file path (#39954)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:29:34 -04:00
opencode-agent[bot] f6b5f6b6b3 refactor(tui): remove prompt re-export barrels (#39953)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:29:27 -04:00
opencode-agent[bot] 02df1e9737 refactor(tui): reuse canonical logo (#39951)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:28:55 -04:00
opencode-agent[bot] 5b84e39f6d refactor(tui): remove stub child cycling commands (#39949)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:28:23 -04:00
opencode-agent[bot] 6d3f84b4ff refactor(tui): remove dialog show helpers (#39948)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:28:02 -04:00
opencode-agent[bot] 255e9bbf06 refactor(tui): remove onboarding hint residue (#39947)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:27:52 -04:00
opencode-agent[bot] 25b6a179e2 refactor(cli): remove no-op migrate command (#39946)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:27:29 -04:00
opencode-agent[bot] 80874c241f refactor(tui): remove dead assistant renderer (#39945)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:27:15 -04:00
opencode-agent[bot] f99469bdf8 refactor(core): remove unused layer exports (#39975)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:26:38 -04:00
opencode-agent[bot] fe0eb4ea8f refactor(core): remove orphaned move service (#39974)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:26:21 -04:00
opencode-agent[bot] a484f87680 refactor(core): remove unused dependencies (#39973)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:26:06 -04:00
opencode-agent[bot] 3b6ab392ac refactor(core): remove unused console state model (#39972)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:25:45 -04:00
opencode-agent[bot] 6c567dc745 refactor(core): remove unreferenced layer map example (#39971)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:25:27 -04:00
opencode-agent[bot] 92567700f6 refactor(tui): remove unused stop voice operation (#39969)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:25:17 -04:00
James Long 90c84639e3 feat(theme): export expandTheme (#39967) 2026-07-31 18:32:55 -04:00
opencode-agent[bot] 56c6add5c3 refactor(core): remove unused helpers (#39943)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-31 16:17:14 -05:00
Kit Langton 2cfe8883ea feat(plugin): expose session rename and wait to plugins (#39932) 2026-07-31 21:08:33 +00:00
Kit Langton 102086c50f feat(tui): hot-reload local TUI plugins (#39776) 2026-07-31 16:35:05 -04:00
Kit Langton 06e26d89ad feat(protocol): accept title on session create (#39935) 2026-07-31 19:39:54 +00:00
Aiden Cline 84dd56ed34 fix(core): bound outbound image history (#39929) 2026-07-31 13:57:04 -05:00
opencode-agent[bot] f92e490bb6 fix(tui): preserve diff hunk boundaries (#39693)
Co-authored-by: James Long <17031+jlongster@users.noreply.github.com>
2026-07-31 14:47:36 -04:00
Kit Langton 0481dba88e fix(tui): improve narrow layouts (#39918) 2026-07-31 14:44:13 -04:00
Kit Langton dd0e41c633 fix(tui): render plugin slots and routes with component semantics (#39917) 2026-07-31 14:30:57 -04:00
opencode-agent[bot] b69e51d835 refactor(ai): simplify provider options (#39924)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-31 13:27:32 -05:00
opencode-agent[bot] e60e8d9387 refactor(core): rename attachments config to media (#39927)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-31 13:26:53 -05:00
Kit Langton 3bfce3fd2d feat(tui): expand pasted text (#39920) 2026-07-31 18:17:35 +00:00
Aiden Cline b498a5c6c4 feat(ai): expand OpenRouter native support (#39907) 2026-07-31 12:57:18 -05:00
Kit Langton db9e942398 fix(core): resize large image attachments (#39919) 2026-07-31 13:24:05 -04:00
Kit Langton 0e26116f68 fix(tui): stabilize open picker 2026-07-31 16:38:48 +00:00
Aiden Cline 3468aa0140 feat(ai): configure chat max tokens field (#39909) 2026-07-31 11:14:08 -05:00
Kit Langton 7757f712a2 test(app): restore stable offset observer ordering (#39901) 2026-07-31 12:13:16 -04:00
Aiden Cline 7129ed6e62 fix(core): respect model input limits (#39797) 2026-07-31 10:41:36 -05:00
Kit Langton 6c37842520 fix(cli): deduplicate Node asset destinations (#39900) 2026-07-31 11:40:38 -04:00
Kit Langton dc3c996892 fix(tui): stabilize generated session titles (#39894) 2026-07-31 11:13:25 -04:00
Kit Langton 7fd12c560c fix(tui): fit project paths in open menu (#39887) 2026-07-31 11:07:12 -04:00
Kit Langton 7aaf4e7750 refactor(session): centralize fallback title policy (#39890) 2026-07-31 10:56:38 -04:00
Kit Langton 1311d909a7 fix(tui): preserve session location during handoff (#39886) 2026-07-31 10:54:36 -04:00
Kit Langton d07d9ae0da fix(tui): remove shells from their location (#39885) 2026-07-31 10:46:43 -04:00
Kit Langton 1d18459cdc fix(tui): align session picker scope (#39784) 2026-07-31 10:32:16 -04:00
Kit Langton db45026c6c fix(tui): default tabs to global scope (#39783) 2026-07-31 10:15:53 -04:00
Kit Langton 77df98db51 Revert "feat(tui): delete current session (#39750)"
This reverts commit 7814568ba0.
2026-07-31 10:01:35 -04:00
Aiden Cline ff2b184af1 feat(ai): support Gemini thinking levels (#39796) 2026-07-30 23:38:00 -05:00
Aiden Cline 671e164f8d refactor(core): contain Codex in OpenAI plugin (#39734) 2026-07-30 21:43:38 -05:00
Aiden Cline 5b4ebf3e9d fix(core): map xAI native options (#39787) 2026-07-30 21:42:53 -05:00
Aiden Cline 98229d466d feat(plugin): add session request hook (#39764) 2026-07-30 21:11:22 -05:00
Kit Langton 5c7e2b5042 fix(tui): clarify open menu project labels (#39780) 2026-07-30 21:34:29 -04:00
Kit Langton 0a6a5d3e80 fix(session): retry failed title generation (#39748) 2026-07-30 21:26:44 -04:00
Kit Langton eb95bd27fe feat(session): make generated titles optional (#39747) 2026-07-30 21:24:34 -04:00
Kit Langton 865f512a44 fix(tui): preserve current selection across list updates (#39774) 2026-07-31 00:48:51 +00:00
Kit Langton a460f02f67 feat(tui): inherit session directory when creating a new session (#39753) 2026-07-30 20:31:38 -04:00
Kit Langton 146fdb9de1 feat(tui): add open menu for sessions and projects (#39752) 2026-07-30 20:08:12 -04:00
Kit Langton b866900417 fix(tui): name deleted session in toast (#39768) 2026-07-30 19:59:27 -04:00
Kit Langton 8e3e94aa26 fix(tui): focus palette settings after layout (#39585) 2026-07-30 19:39:16 -04:00
Aiden Cline cc1289048e refactor(core): isolate AI SDK native mappings (#39761) 2026-07-30 18:38:29 -05:00
Kit Langton 7814568ba0 feat(tui): delete current session (#39750) 2026-07-30 17:06:41 -04:00
Kit Langton 16b247f756 fix(tui): smooth new session tab handoff (#39745) 2026-07-30 16:17:01 -04:00
Kit Langton 9abd9594de fix(simulation): mirror kitty keyboard protocol (#39741) 2026-07-30 20:03:21 +00:00
Kit Langton 22d2012f75 fix(tui): register storybook only for story runs (#39733) 2026-07-30 15:55:05 -04:00
Kit Langton cc883058df feat(tui): reopen closed session tabs (#39731) 2026-07-30 15:54:43 -04:00
Kit Langton 8ebc4c6f85 fix(tui): call session tabs just tabs (#39730) 2026-07-30 15:52:58 -04:00
Kit Langton b0ed9990b3 feat(tui): add option tab shortcuts (#39725) 2026-07-30 15:51:10 -04:00
Kit Langton e1c04dcce6 feat(tui): add temporary new session tab (#39735) 2026-07-30 15:05:34 -04:00
Kit Langton 188c642d8c refactor(tui): flatten project picker list (#39726) 2026-07-30 14:18:16 -04:00
Kit Langton 9d55b223bb fix(core): stop repository discovery at nearest marker (#39714) 2026-07-30 13:28:29 -04:00
Kit Langton ce7a7e4e23 fix(tui): truncate project picker paths (#39678) 2026-07-30 13:13:31 -04:00
Kit Langton cba5ba03e3 fix(tui): show shells from session location (#39691) 2026-07-30 13:12:01 -04:00
Kit Langton 49081a4e24 fix(tui): flash tabs on prompt admission (#39702) 2026-07-30 16:57:36 +00:00
opencode-agent[bot] d6371f2fcd fix(session): update activity on prompt (#39539)
Co-authored-by: James Long <17031+jlongster@users.noreply.github.com>
2026-07-30 16:28:10 +00:00
Aiden Cline 7625cbdf47 feat(core): route providers through native AI (#39615) 2026-07-30 11:26:18 -05:00
opencode-agent[bot] 0ffec67ca3 refactor: remove unused V2 code (#39699)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-30 11:25:03 -05:00
Kit Langton bd132f2614 refactor(core): skip redundant worktree lookup (#39692) 2026-07-30 16:15:07 +00:00
opencode-agent[bot] dcb0df4ac1 fix(core): stop MCP SSE error reconnect loops (#39671)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-30 10:53:33 -05:00
Kit Langton 02f3504055 fix(tui): hide redundant session directory labels (#39686) 2026-07-30 11:37:33 -04:00
James Long f23ee5e4a8 refactor(core): simplify formatter selection (#39575) 2026-07-30 10:59:01 -04:00
Kit Langton 77aa85c589 feat(tui): project picker with footer crossfade (#39566) 2026-07-30 10:39:22 -04:00
Dax Raad a618946b7e fix(tui): reload plugins with config 2026-07-30 10:31:09 -04:00
opencode-agent[bot] f9de608dea chore: refresh bun lockfile (#39617)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-30 04:36:21 +00:00
Aiden Cline 906dc8f5b2 fix(ai): apply catalog settings to provider models (#39613) 2026-07-29 22:42:47 -05:00
Aiden Cline 12971935ab feat(core): parse shell permission commands (#39567) 2026-07-29 22:42:18 -05:00
Kit Langton 78c139b8b5 feat(plugin): add ui.tabs API for session tab control (#39591) 2026-07-29 22:57:39 -04:00
Kit Langton c161978852 feat(tui): prefetch open session tabs after connect (#39589) 2026-07-29 22:23:13 -04:00
Kit Langton cb37a7166a feat(tui): make session tab switching fast for long transcripts (#39568) 2026-07-29 22:23:10 -04:00
Dax Raad 488445a679 fix(tui): correct project-aware session lists 2026-07-29 21:59:33 -04:00
opencode-agent[bot] 97786afdd8 refactor(core): share file diff construction (#39586)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-30 00:28:33 +00:00
Aiden Cline b1a5e8a6ae test(tui): restore compaction event lifecycle (#39581) 2026-07-29 18:11:58 -05:00
Aiden Cline 0ece10af43 fix(core): add mutation permission previews (#39578) 2026-07-29 18:04:13 -05:00
Aiden Cline d1a02b149c fix(core): clarify subagent tool guidance (#39572) 2026-07-29 16:48:49 -05:00
James Long 94ee274aeb feat(core): add V2 formatter runtime (#39564) 2026-07-29 17:23:04 -04:00
Kit Langton 3c259fc552 feat(tui): replace scrap screen with component storybook (#39548) 2026-07-29 15:51:15 -04:00
Aiden Cline 210be4b749 fix(core): preserve shell output on timeout (#39559) 2026-07-29 14:31:26 -05:00
James Long 464649e67e feat(tui): batch event delivery (#39551) 2026-07-29 15:20:39 -04:00
Aiden Cline c8dca936b1 feat(plugin): add shell.create.before hook (#39547) 2026-07-29 14:11:52 -05:00
Kit Langton 4f871906bc fix(tui): support cd before session creation (#39555) 2026-07-29 18:45:42 +00:00
Aiden Cline f7ea2fc346 test(cli): update ACP fork expectation (#39554) 2026-07-29 13:21:34 -05:00
Aiden Cline 5cb633a48e feat(core): support pinned Code Mode tools (#39550) 2026-07-29 13:16:49 -05:00
Kit Langton b985d2eb8e feat(tui): polish session tab animations (#39542) 2026-07-29 13:50:49 -04:00
Dax Raad 014908a8d7 feat(tui): reload config file changes 2026-07-29 13:05:48 -04:00
Dax Raad f599f8f3d3 fix(tui): guard Bun runtime plugin support 2026-07-29 12:57:35 -04:00
Aiden Cline b2010220f9 fix(core): clarify Code Mode tool boundary (#39540) 2026-07-29 11:56:53 -05:00
James Long c2e975c4e6 refactor(plugin): expose resolved TUI theme (#39536) 2026-07-29 12:51:55 -04:00
Dax Raad 6aa250ee5d refactor(tui): flatten state storage path 2026-07-29 12:39:21 -04:00
Dax Raad 8f1e3ff75c docs: record v1 to v2 database migration decisions 2026-07-29 12:37:00 -04:00
Dax Raad 5438dfb751 fix(tui): remove invalid model toasts 2026-07-29 12:36:56 -04:00
Dax Raad 4bd16d6f47 feat(tui): default tabs to cwd scope 2026-07-29 12:35:32 -04:00
Dax Raad 9ee337469d feat(tui): add persistent storage context 2026-07-29 12:33:18 -04:00
Aiden Cline 813c41ff6c fix(core): simplify shell execution boundary (#39530) 2026-07-29 11:16:45 -05:00
Dax Raad 247f14f955 feat(tui): add replaceable prompt footer slot 2026-07-29 10:16:34 -04:00
Dax Raad bd906d468d refactor(core): make watcher subscription effectful 2026-07-29 09:50:24 -04:00
Dax Raad fc11ed3838 feat(session): define explicit fork boundaries 2026-07-29 09:50:24 -04:00
Dax Raad 2a85c861e0 fix(session): hide pending admission sequence 2026-07-29 09:50:24 -04:00
Shoubhit Dash 9554f9a16e feat(ai): type Vertex request options (#39499) 2026-07-29 18:23:00 +05:30
Shoubhit Dash d72b428061 feat(ai): type Vertex Chat request options (#39503) 2026-07-29 18:22:41 +05:30
Shoubhit Dash fea17b4a0e feat(ai): type Cloudflare request options (#39507) 2026-07-29 18:22:16 +05:30
Shoubhit Dash 9038e44a68 feat(ai): type Copilot request options (#39496) 2026-07-29 18:22:01 +05:30
Shoubhit Dash 3b8299e3f2 feat(ai): type Azure request options (#39498) 2026-07-29 18:21:43 +05:30
Shoubhit Dash f5cdf0f056 feat(ai): type Anthropic request options (#39502) 2026-07-29 18:21:19 +05:30
Shoubhit Dash 224feff7c4 feat(ai): type compatible Responses options (#39506) 2026-07-29 18:20:55 +05:30
Shoubhit Dash ce2c9e7e26 feat(ai): type Google request options (#39504) 2026-07-29 18:20:30 +05:30
Shoubhit Dash cb80f47112 feat(ai): type Vertex Messages request options (#39501) 2026-07-29 18:20:04 +05:30
Shoubhit Dash b4ac939537 feat(ai): type xAI request options (#39505) 2026-07-29 18:19:44 +05:30
Shoubhit Dash 8a96b80aec feat(ai): type OpenAI request options (#39495) 2026-07-29 18:19:26 +05:30
Shoubhit Dash 333a090975 feat(ai): type Vertex Responses request options (#39500) 2026-07-29 18:19:02 +05:30
Shoubhit Dash 5a78a17e49 feat(ai): type OpenRouter request options (#39508) 2026-07-29 18:18:43 +05:30
Shoubhit Dash 9d6af6afa4 feat(ai): type compatible request options (#39509) 2026-07-29 18:18:24 +05:30
Shoubhit Dash 8c3e06798c refactor(ai): limit provider option inference (#39510) 2026-07-29 18:18:00 +05:30
Shoubhit Dash 5882b64612 feat(ai): type Anthropic-compatible request options (#39497) 2026-07-29 18:07:24 +05:30
Shoubhit Dash 309c4fe6f0 feat(ai): infer model provider options (#39493) 2026-07-29 18:05:56 +05:30
Shoubhit Dash d9555f138b refactor(ai): internalize request compilation (#39132) 2026-07-29 16:51:11 +05:30
475 changed files with 13346 additions and 7450 deletions
+7 -10
View File
@@ -2,15 +2,12 @@ import type { Context } from "../../../packages/plugin/src/tui/context"
export default { export default {
id: "test.tui-discovery-smoke", id: "test.tui-discovery-smoke",
setup(context: Context) { setup(_context: Context) {
const timer = setTimeout(() => { // context.ui.toast.show({
context.ui.toast.show({ // title: "TUI plugin discovery works",
title: "TUI plugin discovery works", // message: "Loaded .opencode/plugins/tui/discovery-smoke.ts",
message: "Loaded .opencode/plugins/tui/discovery-smoke.ts", // variant: "success",
variant: "success", // duration: 30_000,
duration: 30_000, // })
})
}, 1_000)
return () => clearTimeout(timer)
}, },
} }
+7
View File
@@ -4,6 +4,13 @@
- The default branch in this repo is `dev`. - The default branch in this repo is `dev`.
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs. - Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
## Live V2 TUI Testing
- Run `bun run dev:live` from a development worktree to test its TUI against the currently elected `opencode2` background server and live sessions.
- Pass a directory after the script when needed, for example `bun run dev:live /path/to/project`.
- The script discovers the server with `opencode2 service status`, injects its private local credential from `opencode2 service get password`, and uses the `next` TUI storage channel so tabs and other client-local state match the installed client.
- Prefer `dev:live` over plain `bun run dev` for this workflow. An implicit managed-service connection may replace the live server when the worktree client version differs; explicit `--server` warns and continues without replacing it.
## Branch Names ## Branch Names
Use a short branch name of at most three words, separated by hyphens. Do not use slashes or type prefixes such as `feat/` or `fix/`. Use a short branch name of at most three words, separated by hyphens. Do not use slashes or type prefixes such as `feat/` or `fix/`.
+21 -6
View File
@@ -67,6 +67,7 @@
"@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz", "@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz",
"@opencode-ai/session-ui": "workspace:*", "@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@pierre/trees": "1.0.0-beta.4", "@pierre/trees": "1.0.0-beta.4",
"@sentry/solid": "catalog:", "@sentry/solid": "catalog:",
"@shikijs/transformers": "3.9.2", "@shikijs/transformers": "3.9.2",
@@ -87,7 +88,6 @@
"@tanstack/solid-query": "5.91.4", "@tanstack/solid-query": "5.91.4",
"@tanstack/solid-virtual": "catalog:", "@tanstack/solid-virtual": "catalog:",
"@thisbeyond/solid-dnd": "0.7.5", "@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1", "ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
@@ -143,7 +143,10 @@
"open": "10.1.2", "open": "10.1.2",
"semver": "catalog:", "semver": "catalog:",
"solid-js": "catalog:", "solid-js": "catalog:",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"uqr": "0.1.3", "uqr": "0.1.3",
"web-tree-sitter": "0.25.10",
"ws": "8.21.0", "ws": "8.21.0",
}, },
"devDependencies": { "devDependencies": {
@@ -380,7 +383,6 @@
"@opencode-ai/ai": "workspace:*", "@opencode-ai/ai": "workspace:*",
"@opencode-ai/codemode": "workspace:*", "@opencode-ai/codemode": "workspace:*",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*", "@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*", "@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
@@ -401,9 +403,11 @@
"ignore": "7.0.5", "ignore": "7.0.5",
"immer": "11.1.4", "immer": "11.1.4",
"jsonc-parser": "3.3.1", "jsonc-parser": "3.3.1",
"semver": "^7.6.3", "tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0", "turndown": "7.2.0",
"venice-ai-sdk-provider": "2.1.1", "venice-ai-sdk-provider": "2.1.1",
"web-tree-sitter": "0.25.10",
"which": "6.0.1", "which": "6.0.1",
"zod": "catalog:", "zod": "catalog:",
}, },
@@ -420,7 +424,6 @@
"@tsconfig/bun": "catalog:", "@tsconfig/bun": "catalog:",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/node": "catalog:", "@types/node": "catalog:",
"@types/semver": "catalog:",
"@types/turndown": "5.0.5", "@types/turndown": "5.0.5",
"@types/which": "3.0.4", "@types/which": "3.0.4",
"drizzle-kit": "catalog:", "drizzle-kit": "catalog:",
@@ -514,6 +517,7 @@
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/session-ui": "workspace:*", "@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:", "@solidjs/router": "catalog:",
@@ -600,6 +604,7 @@
"zod": "catalog:", "zod": "catalog:",
}, },
"devDependencies": { "devDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": "catalog:", "@opentui/core": "catalog:",
"@opentui/keymap": "catalog:", "@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:", "@opentui/solid": "catalog:",
@@ -611,12 +616,14 @@
"typescript": "catalog:", "typescript": "catalog:",
}, },
"peerDependencies": { "peerDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": ">=0.4.5", "@opentui/core": ">=0.4.5",
"@opentui/keymap": ">=0.4.5", "@opentui/keymap": ">=0.4.5",
"@opentui/solid": ">=0.4.5", "@opentui/solid": ">=0.4.5",
"solid-js": ">=1.9.0", "solid-js": ">=1.9.0",
}, },
"optionalPeers": [ "optionalPeers": [
"@opencode-ai/theme",
"@opentui/core", "@opentui/core",
"@opentui/keymap", "@opentui/keymap",
"@opentui/solid", "@opentui/solid",
@@ -1070,9 +1077,11 @@
}, },
"trustedDependencies": [ "trustedDependencies": [
"esbuild", "esbuild",
"tree-sitter-powershell",
"protobufjs", "protobufjs",
"electron", "electron",
"web-tree-sitter", "web-tree-sitter",
"tree-sitter-bash",
], ],
"patchedDependencies": { "patchedDependencies": {
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
@@ -4069,10 +4078,10 @@
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
"ffi-rs": ["ffi-rs@1.3.2", "", { "optionalDependencies": { "@yuuang/ffi-rs-android-arm64": "1.3.2", "@yuuang/ffi-rs-darwin-arm64": "1.3.2", "@yuuang/ffi-rs-darwin-x64": "1.3.2", "@yuuang/ffi-rs-linux-arm-gnueabihf": "1.3.2", "@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2", "@yuuang/ffi-rs-linux-arm64-musl": "1.3.2", "@yuuang/ffi-rs-linux-x64-gnu": "1.3.2", "@yuuang/ffi-rs-linux-x64-musl": "1.3.2", "@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2", "@yuuang/ffi-rs-win32-ia32-msvc": "1.3.2", "@yuuang/ffi-rs-win32-x64-msvc": "1.3.2" } }, "sha512-4s8dX9VbBw/jd5NOuE3EJRqXaIVdjMyiumeeDzrOhtjQRwp6Bz2za7iksWXTnvTQKV/tTdm1s1w7mObe92zPjQ=="], "ffi-rs": ["ffi-rs@1.3.2", "", { "optionalDependencies": { "@yuuang/ffi-rs-android-arm64": "1.3.2", "@yuuang/ffi-rs-darwin-arm64": "1.3.2", "@yuuang/ffi-rs-darwin-x64": "1.3.2", "@yuuang/ffi-rs-linux-arm-gnueabihf": "1.3.2", "@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2", "@yuuang/ffi-rs-linux-arm64-musl": "1.3.2", "@yuuang/ffi-rs-linux-x64-gnu": "1.3.2", "@yuuang/ffi-rs-linux-x64-musl": "1.3.2", "@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2", "@yuuang/ffi-rs-win32-ia32-msvc": "1.3.2", "@yuuang/ffi-rs-win32-x64-msvc": "1.3.2" } }, "sha512-4s8dX9VbBw/jd5NOuE3EJRqXaIVdjMyiumeeDzrOhtjQRwp6Bz2za7iksWXTnvTQKV/tTdm1s1w7mObe92zPjQ=="],
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
"filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="], "filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
@@ -5631,6 +5640,10 @@
"traverse": ["traverse@0.3.9", "", {}, "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ=="], "traverse": ["traverse@0.3.9", "", {}, "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ=="],
"tree-sitter-bash": ["tree-sitter-bash@0.25.0", "", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-gZtlj9+qFS81qKxpLfD6H0UssQ3QBc/F0nKkPsiFDyfQF2YBqYvglFJUzchrPpVhZe9kLZTrJ9n2J6lmka69Vg=="],
"tree-sitter-powershell": ["tree-sitter-powershell@0.25.10", "", { "dependencies": { "node-addon-api": "^7.1.0", "node-gyp-build": "^4.8.0" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-bEt8QoySpGFnU3aa8WedQyNMaN6aTwy/WUbvIVt0JSKF+BbJoSHNHu+wCbhj7xLMsfB0AuffmiJm+B8gzva8Lg=="],
"treeverse": ["treeverse@3.0.0", "", {}, "sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ=="], "treeverse": ["treeverse@3.0.0", "", {}, "sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
@@ -6853,6 +6866,8 @@
"topojson-client/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "topojson-client/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"tree-sitter-bash/node-addon-api": ["node-addon-api@8.9.0", "", {}, "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q=="],
"tw-to-css/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "tw-to-css/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"tw-to-css/tailwindcss": ["tailwindcss@3.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.2.12", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.18.2", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.0.0", "postcss": "^8.4.23", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w=="], "tw-to-css/tailwindcss": ["tailwindcss@3.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.2.12", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.18.2", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.0.0", "postcss": "^8.4.23", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w=="],
+118
View File
@@ -0,0 +1,118 @@
# V1 to V2 Database Migration
## Approach
- Use the `dev` branch database schema and migration registry as the V1 baseline.
- Remove migrations that exist only on the V2 branch.
- Generate one canonical migration from the `dev` schema to the final V2 schema.
- Add explicit data operations to that migration where generated DDL is insufficient.
- Test the migration against a populated database at the exact `dev` schema.
## Preserve
The canonical V1 data remains in its existing tables. In particular, preserve `session`, `message`, and `part` rows.
Preserve `workspace` rows and existing `session.workspace_id` values unchanged. The migration must not clear or rebuild
workspace relationships.
Keep the `todo` table and its data unchanged. V2 does not currently migrate todos into another representation, and the
generated migration must not drop the table.
## Truncate
Truncate these pre-launch V2 tables before applying schema changes:
- `event`
- `event_sequence`
- `session_message`
These rows are not canonical V1 data. Truncating `event` before adding the required `event.created` column means the
column needs neither a backfill nor a default. After truncation, rebuild `session_message` from canonical V1 `message`
and `part` rows rather than retaining its pre-launch V2 contents.
## Message Backfill
Backfill canonical V1 history from `message` and `part` into `session_message`. This is the main data transformation in
the migration. Preserving the V1 tables alone keeps the data safe but does not make existing history visible through the
V2 session APIs, which read `session_message`.
Reuse each V1 `message.id` as the corresponding `session_message.id`. Stable IDs keep the migration deterministic and
avoid rewriting other persisted state that may refer to a message.
Within each session, order V1 messages by `time_created` and then `id`, matching the existing V1 message index. Assign
contiguous `session_message.seq` values starting at `0`.
Map ordinary V1 messages one-to-one by role. Each ordinary V1 user message becomes one V2 `user` row, and each ordinary
V1 assistant message becomes one V2 `assistant` row. Fold the source message's ordered V1 parts into that row's V2
payload.
Handle semantic marker parts before applying the ordinary mapping. In particular, a V1 user message containing a
`compaction` part and its paired assistant summary represent one compaction operation, not two ordinary messages. Special
part mappings must be decided explicitly before implementing the backfill.
V1 synthetic content is represented by user text parts with `synthetic: true`, not by a separate message role. A V1 user
message whose visible text parts are all synthetic should become a V2 `synthetic` message. If a V1 user message mixes
ordinary and synthetic content, preserve the ordinary content in the V2 `user` row and emit the synthetic content as an
adjacent V2 `synthetic` row. Ignore text parts marked `ignored`, matching V1 model-history behavior.
Use the V1 compaction user message ID as the ID of the collapsed V2 compaction message. This matches V2's use of the
admitted compaction input ID and preserves references to the initiating message.
For a completed compaction, create one V2 `compaction` row with `status: "completed"`. Set `reason` from the V1
compaction part's `auto` flag, join the paired summary assistant's nonempty text parts with blank lines for `summary`, and
serialize the retained V1 tail beginning at `tail_start_id` for `recent`. Use an empty `recent` value when no tail was
retained, and use the compaction user message creation time. Do not emit the paired summary assistant as a separate V2
assistant row.
After rebuilding `session_message`, seed `event_sequence` with one row per migrated session. Set its watermark to that
session's maximum backfilled `session_message.seq`. This prevents new V2 events from reusing sequence numbers or sorting
before migrated history. The `event` table remains empty.
## Drop
Drop these pre-launch V2 tables without preserving or transforming their rows:
- `session_input`
- `session_context_epoch`
Do not transfer `session_input` rows into `session_pending`.
## Create Empty
Let the generated migration create these tables empty:
- `instruction_blob`
- `instruction_entry`
- `instruction_state`
- `session_pending`
- `kv`
V1 has no canonical data to backfill into these tables. V2 initializes their state as it runs.
## Fork Storage
V1 has no fork-boundary state to backfill. New V2 forks use a required message boundary and persist it in
`session.fork_boundary`. The durable fork event contains no parent sequence. Its resolved boundary is one of:
- `before`: copy messages before the identified message.
- `through`: copy messages through the identified message.
Forking an empty session is not supported. `session.fork_seq` and `session.fork_message_id` are not part of the final V2
schema.
New nullable session columns, including `fork_session_id`, `fork_boundary`, and `time_suspended`, require no explicit
backfill. Existing rows naturally receive `NULL` when the generated migration adds the columns.
## Verification
The canonical migration test should seed representative V1 sessions, messages, parts, todos, projects, accounts,
credentials, permissions, shares, and workspaces. After migration, it should verify:
- Preserved rows and encoded values remain unchanged.
- Todo rows remain available in the unchanged `todo` table.
- `event` is empty, and stale pre-launch rows are absent from the rebuilt projections.
- Backfilled `session_message` rows represent the canonical V1 `message` and `part` history.
- Each migrated session's `event_sequence` watermark matches its maximum backfilled message sequence.
- Dropped tables no longer exist.
- New tables exist and are empty.
- The final schema has no ungenerated changes.
+1
View File
@@ -8,6 +8,7 @@
"packageManager": "bun@1.3.14", "packageManager": "bun@1.3.14",
"scripts": { "scripts": {
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts", "dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
"dev:live": "OPENCODE_TUI_CHANNEL=next OPENCODE_PASSWORD=\"$(opencode2 service get password)\" bun run dev --server \"$(opencode2 service status)\"",
"dev:desktop": "bun --cwd packages/desktop dev", "dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev", "dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev", "dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
+10 -8
View File
@@ -10,7 +10,9 @@
## Conventions ## Conventions
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `Model.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, `LLM.updateRequest`, and `LLM.generateObject`. Two ways to construct the same thing is one too many. Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `LanguageModel.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, `LLM.updateRequest`, and `LLM.generateObject`. Two ways to construct the same thing is one too many.
- Keep provider-defined string enums forward-compatible. Expose known values for autocomplete while accepting future values with `Known | (string & {})`; use `Schema.String` at runtime unless rejecting unknown values is required for correctness.
## Tests ## Tests
@@ -46,7 +48,7 @@ const response = yield * LLMClient.generate(request)
`LLM.request(...)` builds an `LLMRequest`. `LLMClient.generate(...)` reads the executable route carried by `request.model.route`, builds the provider-native body, asks the route's transport for a real `HttpClientRequest.HttpClientRequest`, sends it through `RequestExecutor.Service`, parses the provider stream into common `LLMEvent`s, and finally returns an `LLMResponse`. `LLM.request(...)` builds an `LLMRequest`. `LLMClient.generate(...)` reads the executable route carried by `request.model.route`, builds the provider-native body, asks the route's transport for a real `HttpClientRequest.HttpClientRequest`, sends it through `RequestExecutor.Service`, parses the provider stream into common `LLMEvent`s, and finally returns an `LLMResponse`.
Use `LLMClient.stream(request)` when callers want incremental `LLMEvent`s. Use `LLMClient.generate(request)` when callers want those same events collected into an `LLMResponse`. Use `LLMClient.prepare<Body>(request)` to compile a request through the route pipeline without sending it — the optional `Body` type argument narrows `.body` to the route's native shape (e.g. `prepare<OpenAIChatBody>(...)` returns a `PreparedRequestOf<OpenAIChatBody>`). The runtime body is identical; the generic is a type-level assertion. Use `LLMClient.stream(request)` when callers want incremental `LLMEvent`s. Use `LLMClient.generate(request)` when callers want those same events collected into an `LLMResponse`.
Filter or narrow `LLMEvent` streams with `LLMEvent.is.*` (camelCase guards, e.g. `events.filter(LLMEvent.is.toolCall)`). The kebab-case `LLMEvent.guards["tool-call"]` form also works but prefer `is.*` in new code. Filter or narrow `LLMEvent` streams with `LLMEvent.is.*` (camelCase guards, e.g. `events.filter(LLMEvent.is.toolCall)`). The kebab-case `LLMEvent.guards["tool-call"]` form also works but prefer `is.*` in new code.
@@ -74,7 +76,7 @@ export const route = Route.make({
}) })
``` ```
Route defaults are request-shaping defaults such as `headers`, `limits`, `generation`, `providerOptions`, and `http`. Endpoint host/query belongs on the route endpoint. Selected `Model` values carry only model id, provider id, and the configured route value. Model capability/catalog metadata lives outside this package; protocol support is enforced by request lowering and typed `LLMError`s. Route defaults are request-shaping defaults such as `headers`, `limits`, `generation`, `providerOptions`, and `http`. Endpoint host/query belongs on the route endpoint. Selected `LanguageModel` values carry only model id, provider id, and the configured route value. Model capability/catalog metadata lives outside this package; protocol support is enforced by request lowering and typed `AIError`s.
The four-axis decomposition is the reason DeepSeek, TogetherAI, Cerebras, Baseten, Fireworks, and DeepInfra all reuse `OpenAIChat.protocol` verbatim — each provider deployment is a 5-15 line `Route.make(...)` call instead of a 300-400 line route clone. Bug fixes in one protocol propagate to every consumer of that protocol in a single commit. The four-axis decomposition is the reason DeepSeek, TogetherAI, Cerebras, Baseten, Fireworks, and DeepInfra all reuse `OpenAIChat.protocol` verbatim — each provider deployment is a 5-15 line `Route.make(...)` call instead of a 300-400 line route clone. Bug fixes in one protocol propagate to every consumer of that protocol in a single commit.
@@ -126,7 +128,7 @@ const selected = model("gpt-5", {
}) })
``` ```
Keep semantic APIs as separate entrypoints, such as OpenAI `chat` and `responses`. Keep transport choices inside the semantic entrypoint settings, so OpenAI Responses HTTP and WebSocket share one entrypoint. Provider facades may still expose named selectors such as `responsesWebSocket` for direct typed call sites; the package-like contract maps its settings to those selectors before returning an executable `Model`. Keep semantic APIs as separate entrypoints, such as OpenAI `chat` and `responses`. Keep transport choices inside the semantic entrypoint settings, so OpenAI Responses HTTP and WebSocket share one entrypoint. Provider facades may still expose named selectors such as `responsesWebSocket` for direct typed call sites; the package-like contract maps its settings to those selectors before returning an executable `LanguageModel`.
Do not expose `Route` in provider package settings. Route composition stays an implementation detail behind `model(...)`. Do not expose `Route` in provider package settings. Route composition stays an implementation detail behind `model(...)`.
@@ -136,15 +138,15 @@ Do not expose `Route` in provider package settings. Route composition stays an i
packages/ai/src/ packages/ai/src/
schema/ canonical Schema model, split by concern schema/ canonical Schema model, split by concern
ids.ts branded IDs, literal types, ProviderMetadata ids.ts branded IDs, literal types, ProviderMetadata
options.ts Generation/Provider/Http options, Limits, Model, cache policy options.ts Generation/Provider/Http options, Limits, LanguageModel, cache policy
messages.ts content parts, Message, ToolDefinition, LLMRequest messages.ts content parts, Message, ToolDefinition, LLMRequest
events.ts Usage, individual events, LLMEvent, PreparedRequest, LLMResponse events.ts Usage, individual events, LLMEvent, LLMResponse
errors.ts error reasons, LLMError, ToolFailure errors.ts error reasons, AIError, ToolFailure
index.ts barrel index.ts barrel
llm.ts request constructors and convenience helpers llm.ts request constructors and convenience helpers
route/ route/
index.ts @opencode-ai/ai/route advanced barrel index.ts @opencode-ai/ai/route advanced barrel
client.ts Route.make + LLMClient.prepare/stream/generate client.ts Route.make + LLMClient.stream/generate
executor.ts RequestExecutor service + transport error mapping executor.ts RequestExecutor service + transport error mapping
protocol.ts Protocol type + Protocol.make protocol.ts Protocol type + Protocol.make
endpoint.ts Endpoint type + Endpoint.path endpoint.ts Endpoint type + Endpoint.path
+6 -6
View File
@@ -96,7 +96,7 @@ contains identity, capabilities, pricing metadata, provider-specific option
types, reusable request-behavior defaults, and hidden execution behavior. types, reusable request-behavior defaults, and hidden execution behavior.
Normal users do not need to learn the current `Route` composite. Protocol, Normal users do not need to learn the current `Route` composite. Protocol,
endpoint, auth, transport, and hooks are bound behind `Model`. endpoint, auth, transport, and hooks are bound behind `LanguageModel`.
### Request ### Request
@@ -539,7 +539,7 @@ Hosted tools do not pretend to have local handlers, and callers do not inspect a
### Run stream ### Run stream
`LLM.stream` returns an Effect `Stream<RunEvent, LLMError, Requirements>`. `LLM.stream` returns an Effect `Stream<RunEvent, AIError, Requirements>`.
Run events explicitly expose orchestration boundaries: Run events explicitly expose orchestration boundaries:
```ts ```ts
@@ -828,11 +828,11 @@ portable semantic guarantee.
## Error Model ## Error Model
The Effect error channel is a tagged domain union rather than one `LLMError` The Effect error channel is a tagged domain union rather than one `AIError`
wrapper with nested reasons. Illustrative categories: wrapper with nested reasons. Illustrative categories:
```ts ```ts
type LLMError = type AIError =
| AuthenticationError | AuthenticationError
| InvalidRequestError | InvalidRequestError
| UnsupportedCapabilityError | UnsupportedCapabilityError
@@ -1079,7 +1079,7 @@ The redesign intentionally removes or changes these current concepts:
| `LLM.generate` means one turn | `LLM.generate` means complete run | | `LLM.generate` means one turn | `LLM.generate` means complete run |
| `LLMClient.generate/stream` | `LLM.generateTurn/streamTurn` for one turn | | `LLMClient.generate/stream` | `LLM.generateTurn/streamTurn` for one turn |
| `LLMClient.layer` requirement | Standard Effect requirements exposed directly | | `LLMClient.layer` requirement | Standard Effect requirements exposed directly |
| Public `Route` mental model | Hidden behind executable `Model` | | Public `Route` mental model | Hidden behind executable `LanguageModel` |
| `Provider.make` structural helper | Experimental declarative `Provider.define` | | `Provider.make` structural helper | Experimental declarative `Provider.define` |
| Schema classes as canonical values | Plain immutable values plus schema subpath | | Schema classes as canonical values | Plain immutable values plus schema subpath |
| `LLM.updateRequest` | Object spread | | `LLM.updateRequest` | Object spread |
@@ -1089,7 +1089,7 @@ The redesign intentionally removes or changes these current concepts:
| `generateObject` | Typed `output` option on `generate` | | `generateObject` | Typed `output` option on `generate` |
| One event union for provider output | Separate `TurnEvent` and `RunEvent` unions | | One event union for provider output | Separate `TurnEvent` and `RunEvent` unions |
| `providerExecuted` dispatch check | Distinct hosted-tool constructors | | `providerExecuted` dispatch check | Distinct hosted-tool constructors |
| One wrapped `LLMError` | Tagged domain error union | | One wrapped `AIError` | Tagged domain error union |
OpenCode should migrate to `generateTurn` / `streamTurn`, preserving its durable OpenCode should migrate to `generateTurn` / `streamTurn`, preserving its durable
prompt admission, persistence, permission, tool settlement, and continuation prompt admission, persistence, permission, tool settlement, and continuation
+1 -2
View File
@@ -195,8 +195,7 @@ The hosted result is represented as a provider-executed tool call and tool resul
- **`LLM.request({...})`** — build a provider-neutral `LLMRequest`. Accepts ergonomic inputs (`system: string`, `prompt: string`) that normalize into the canonical Schema classes. - **`LLM.request({...})`** — build a provider-neutral `LLMRequest`. Accepts ergonomic inputs (`system: string`, `prompt: string`) that normalize into the canonical Schema classes.
- **`LLM.generate` / `LLM.stream`** — re-exported from `LLMClient` for one-import use. - **`LLM.generate` / `LLM.stream`** — re-exported from `LLMClient` for one-import use.
- **`Message.user(...)` / `Message.assistant(...)` / `Message.tool(...)`** — message constructors from the canonical schema model. - **`Message.user(...)` / `Message.assistant(...)` / `Message.tool(...)`** — message constructors from the canonical schema model.
- **`Model.make(...)` / `ToolCallPart.make(...)` / `ToolResultPart.make(...)` / `ToolDefinition.make(...)`** — model and tool-related constructors from the canonical schema model. - **`LanguageModel.make(...)` / `ToolCallPart.make(...)` / `ToolResultPart.make(...)` / `ToolDefinition.make(...)`** — model and tool-related constructors from the canonical schema model.
- **`LLMClient.prepare(request)`** — compile a request through protocol body construction, validation, and HTTP preparation without sending. Useful for inspection and testing.
- **`LLMEvent.is.*`** — typed guards (`is.textDelta`, `is.toolCall`, `is.finish`, …) for filtering streams. - **`LLMEvent.is.*`** — typed guards (`is.textDelta`, `is.toolCall`, `is.finish`, …) for filtering streams.
- **`Image.generate({...})`** — generate images through a provider-neutral image request and response model. - **`Image.generate({...})`** — generate images through a provider-neutral image request and response model.
- **`ImageClient`** — Effect service and layer for image execution, parallel to `LLMClient`. - **`ImageClient`** — Effect service and layer for image execution, parallel to `LLMClient`.
+23 -23
View File
@@ -60,7 +60,7 @@ Other `aisdk:` packages, including Google Vertex, Azure, and Bedrock, currently
| `@ai-sdk/google-vertex/xai` | Vertex Chat / Responses | Partial / usable | Decide Chat/Responses selection for catalog models, add runner mapping and recorded coverage, and review xAI-specific request options. | | `@ai-sdk/google-vertex/xai` | Vertex Chat / Responses | Partial / usable | Decide Chat/Responses selection for catalog models, add runner mapping and recorded coverage, and review xAI-specific request options. |
| `@ai-sdk/azure` | Azure OpenAI Chat/Responses facade | Partial | Map runner/catalog metadata to native Azure, handle resourceName/baseURL/apiVersion variants, add AAD/token auth story, and verify Chat vs Responses deployment selection. | | `@ai-sdk/azure` | Azure OpenAI Chat/Responses facade | Partial | Map runner/catalog metadata to native Azure, handle resourceName/baseURL/apiVersion variants, add AAD/token auth story, and verify Chat vs Responses deployment selection. |
| `@ai-sdk/amazon-bedrock` | Bedrock Converse | Partial | Add default AWS credential chain/profile support, region/inference-profile model ID handling, provider option parity via `additionalModelRequestFields`, guardrails/performance config, and runner/catalog mapping. | | `@ai-sdk/amazon-bedrock` | Bedrock Converse | Partial | Add default AWS credential chain/profile support, region/inference-profile model ID handling, provider option parity via `additionalModelRequestFields`, guardrails/performance config, and runner/catalog mapping. |
| `@ai-sdk/amazon-bedrock/mantle` | Bedrock Mantle OpenAI-compatible Chat/Responses namespace | Missing | Decide native Mantle shape, likely separate from Converse because it uses OpenAI-compatible Chat/Responses semantics over Bedrock. Add package mapping and tests. | | `@ai-sdk/amazon-bedrock/mantle` | Bedrock Mantle OpenAI-compatible Chat/Responses namespace | Partial / usable | Add default AWS credential chain/profile support; native catalog mapping currently requires bearer auth or explicit static credentials. |
## Highest-Risk Gaps ## Highest-Risk Gaps
@@ -71,30 +71,31 @@ Other `aisdk:` packages, including Google Vertex, Azure, and Bedrock, currently
5. Azure is only a provider facade, not a full runtime replacement. Native Azure exists, but the catalog runner does not select it, and token auth/resource variants need review. 5. Azure is only a provider facade, not a full runtime replacement. Native Azure exists, but the catalog runner does not select it, and token auth/resource variants need review.
6. Provider option typing is uneven. OpenAI, Anthropic, Gemini, Bedrock, and OpenRouter each expose a small typed subset plus raw HTTP overlays; this is useful but not equivalent to AI SDK provider option coverage. 6. Provider option typing is uneven. OpenAI, Anthropic, Gemini, Bedrock, and OpenRouter each expose a small typed subset plus raw HTTP overlays; this is useful but not equivalent to AI SDK provider option coverage.
7. Structured output is not provider-native yet. `LLM.generateObject` still uses a synthetic tool strategy, while the future design expects native structured output where reliable and tool fallback where needed. 7. Structured output is not provider-native yet. `LLM.generateObject` still uses a synthetic tool strategy, while the future design expects native structured output where reliable and tool fallback where needed.
8. Package/namespace boundaries for the current native loading set are explicit in docs and exports. Other exported provider facades are not catalog package entrypoints until they implement the contract. Vertex xAI still needs catalog API selection; the missing native boundary is Bedrock Mantle. 8. Package/namespace boundaries for the current native loading set are explicit in docs and exports. Other exported provider facades are not catalog package entrypoints until they implement the contract. Vertex xAI still needs catalog API selection.
9. Recorded coverage is uneven. OpenAI, Anthropic, Gemini, Bedrock Converse, Cloudflare, OpenRouter, and several OpenAI-compatible Chat providers have cassettes. Azure, Vertex, and Mantle need first-class recorded scenarios before switching defaults. 9. Recorded coverage is uneven. OpenAI, Anthropic, Gemini, Bedrock Converse, Bedrock Mantle, Cloudflare, OpenRouter, and several OpenAI-compatible Chat providers have cassettes. Azure and Vertex still need first-class recorded scenarios before switching defaults.
## Native Namespace Shape ## Native Namespace Shape
These are implementation/API slices, not separate npm packages. These are implementation/API slices, not separate npm packages.
| API slice | Package-like entrypoint | Purpose | | API slice | Package-like entrypoint | Purpose |
| ----------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------- | | ----------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------- |
| OpenAI Chat | `@opencode-ai/ai/providers/openai/chat` | OpenAI `/chat/completions` semantics. | | OpenAI Chat | `@opencode-ai/ai/providers/openai/chat` | OpenAI `/chat/completions` semantics. |
| OpenAI Responses | `@opencode-ai/ai/providers/openai/responses` | OpenAI `/responses` semantics with HTTP/WebSocket selected through settings. | | OpenAI Responses | `@opencode-ai/ai/providers/openai/responses` | OpenAI `/responses` semantics with HTTP/WebSocket selected through settings. |
| OpenAI-compatible Chat | `@opencode-ai/ai/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. | | OpenAI-compatible Chat | `@opencode-ai/ai/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. |
| Open Responses-compatible | `@opencode-ai/ai/providers/openai-compatible/responses` | Generic provider-neutral `/responses`. | | Open Responses-compatible | `@opencode-ai/ai/providers/openai-compatible/responses` | Generic provider-neutral `/responses`. |
| Anthropic-compatible Messages | `@opencode-ai/ai/providers/anthropic-compatible` | Generic Anthropic-compatible `/messages`. | | Anthropic-compatible Messages | `@opencode-ai/ai/providers/anthropic-compatible` | Generic Anthropic-compatible `/messages`. |
| Anthropic Messages | `@opencode-ai/ai/providers/anthropic` | Anthropic Messages API. | | Anthropic Messages | `@opencode-ai/ai/providers/anthropic` | Anthropic Messages API. |
| Gemini Developer API | `@opencode-ai/ai/providers/google` | Google AI Studio Gemini API. | | Gemini Developer API | `@opencode-ai/ai/providers/google` | Google AI Studio Gemini API. |
| Vertex Gemini | `@opencode-ai/ai/providers/google-vertex/gemini` | Vertex Gemini API; `providers/google-vertex` is the default alias. | | Vertex Gemini | `@opencode-ai/ai/providers/google-vertex/gemini` | Vertex Gemini API; `providers/google-vertex` is the default alias. |
| Vertex Chat | `@opencode-ai/ai/providers/google-vertex/chat` | Vertex OpenAI-compatible Chat Completions for MaaS models. | | Vertex Chat | `@opencode-ai/ai/providers/google-vertex/chat` | Vertex OpenAI-compatible Chat Completions for MaaS models. |
| Vertex Responses | `@opencode-ai/ai/providers/google-vertex/responses` | Vertex Open Responses for Grok models. | | Vertex Responses | `@opencode-ai/ai/providers/google-vertex/responses` | Vertex Open Responses for Grok models. |
| Vertex Messages | `@opencode-ai/ai/providers/google-vertex/messages` | Vertex-hosted Anthropic Messages API. | | Vertex Messages | `@opencode-ai/ai/providers/google-vertex/messages` | Vertex-hosted Anthropic Messages API. |
| Bedrock Converse | `@opencode-ai/ai/providers/amazon-bedrock` | AWS Bedrock Converse API. | | Bedrock Converse | `@opencode-ai/ai/providers/amazon-bedrock` | AWS Bedrock Converse API. |
| Bedrock Mantle | Missing | AWS Bedrock Mantle OpenAI-compatible APIs. | | Bedrock Mantle Chat | `@opencode-ai/ai/providers/amazon-bedrock/mantle/chat` | AWS Bedrock Mantle OpenAI-compatible Chat API. |
| Azure OpenAI Chat | `@opencode-ai/ai/providers/azure/chat` | Azure specialization of OpenAI Chat. | | Bedrock Mantle Responses | `@opencode-ai/ai/providers/amazon-bedrock/mantle/responses` | AWS Bedrock Mantle OpenAI-compatible Responses API. |
| Azure OpenAI Responses | `@opencode-ai/ai/providers/azure/responses` | Azure specialization of OpenAI Responses. | | Azure OpenAI Chat | `@opencode-ai/ai/providers/azure/chat` | Azure specialization of OpenAI Chat. |
| Azure OpenAI Responses | `@opencode-ai/ai/providers/azure/responses` | Azure specialization of OpenAI Responses. |
## Suggested Next Work Slices ## Suggested Next Work Slices
@@ -103,6 +104,5 @@ These are implementation/API slices, not separate npm packages.
3. Bring Bedrock native auth/config to AI SDK parity: region, profile, default AWS credential chain, bearer token env, endpoint override, and cross-region inference profile handling. 3. Bring Bedrock native auth/config to AI SDK parity: region, profile, default AWS credential chain, bearer token env, endpoint override, and cross-region inference profile handling.
4. Add runner/catalog mappings and recorded scenarios for the native Vertex Gemini, Chat, Responses, and Messages entrypoints. 4. Add runner/catalog mappings and recorded scenarios for the native Vertex Gemini, Chat, Responses, and Messages entrypoints.
5. Decide Chat/Responses selection for `@ai-sdk/google-vertex/xai` catalog models. 5. Decide Chat/Responses selection for `@ai-sdk/google-vertex/xai` catalog models.
6. Add Bedrock Mantle as a separate OpenAI-compatible Bedrock namespace after deciding whether it uses Chat, Responses, or both by model. 6. Expand typed provider options from the existing V1 lowerer knowledge in `packages/core/src/v1/config/provider-options.ts` before adding more raw overlay examples.
7. Expand typed provider options from the existing V1 lowerer knowledge in `packages/core/src/v1/config/provider-options.ts` before adding more raw overlay examples. 7. Add recorded provider tests for Azure, Vertex Gemini, Vertex Chat, Vertex Responses, Vertex Messages, and Bedrock credential-chain behavior before making native runtime the default for those packages.
8. Add recorded provider tests for Azure, Vertex Gemini, Vertex Chat, Vertex Responses, Vertex Messages, Bedrock credential-chain behavior, and Mantle before making native runtime the default for those packages.
+11 -11
View File
@@ -33,7 +33,7 @@ Keep durable identity separate from runtime capability:
- Durable identity is small serializable data like `{ providerID, modelID }` for - Durable identity is small serializable data like `{ providerID, modelID }` for
config, sessions, logs, and catalogs. config, sessions, logs, and catalogs.
- Runtime capability is a `Model` with a route value, protocol, transport, auth, - Runtime capability is a `LanguageModel` with a route value, protocol, transport, auth,
and defaults. It is allowed to contain functions and schemas. and defaults. It is allowed to contain functions and schemas.
- If persisted identity needs to become executable, resolve it through an app - If persisted identity needs to become executable, resolve it through an app
boundary first. Do not make `LLMRequest` recover behavior from a global route boundary first. Do not make `LLMRequest` recover behavior from a global route
@@ -137,7 +137,7 @@ starts hiding the real provider-specific config.
- accepts model id only - accepts model id only
- returns executable models - returns executable models
- does not accept endpoint/auth/deployment overrides - does not accept endpoint/auth/deployment overrides
4. **Model** 4. **Language Model**
- model id - model id
- route value - route value
- provider id - provider id
@@ -164,7 +164,7 @@ execution mechanism:
```ts ```ts
type ProviderFacade<APIs, Config> = { type ProviderFacade<APIs, Config> = {
readonly id: ProviderID readonly id: ProviderID
readonly model: (id: string) => Model readonly model: (id: string) => LanguageModel
readonly configure: (input?: Config) => ProviderFacade<APIs, Config> readonly configure: (input?: Config) => ProviderFacade<APIs, Config>
} & APIs } & APIs
``` ```
@@ -181,8 +181,8 @@ export const OpenAI = {
configure: configureOpenAI, configure: configureOpenAI,
} satisfies ProviderFacade< } satisfies ProviderFacade<
{ {
responses: (id: string) => Model responses: (id: string) => LanguageModel
chat: (id: string) => Model chat: (id: string) => LanguageModel
}, },
OpenAIConfig OpenAIConfig
> >
@@ -528,7 +528,7 @@ The chosen split is:
```txt ```txt
Route = execution mechanics Route = execution mechanics
Provider facade = configured route group Provider facade = configured route group
Model = selected executable model carrying route value LanguageModel = selected executable model carrying route value
App boundary = explicit durable-config -> typed-provider call App boundary = explicit durable-config -> typed-provider call
``` ```
@@ -549,13 +549,13 @@ App boundary = explicit durable-config -> typed-provider call
entrypoint maps its scoped `transport` setting before constructing the model. entrypoint maps its scoped `transport` setting before constructing the model.
- No separate public `LLMClient.layerWithWebSocket`. The runtime should expose one - No separate public `LLMClient.layerWithWebSocket`. The runtime should expose one
client layer with the available transport capabilities. client layer with the available transport capabilities.
- No executable `ModelRef`. The executable handle is `Model`; durable model - No executable `ModelRef`. The executable handle is `LanguageModel`; durable model
identity stays separate and cannot execute on its own. identity stays separate and cannot execute on its own.
## Implementation Todo ## Implementation Todo
- [x] Replace the current executable `ModelRef` with `Model`. - [x] Replace the current executable `ModelRef` with `LanguageModel`.
- [x] Change `Model.route` to carry a route value, not a `RouteID` string. - [x] Change `LanguageModel.route` to carry a route value, not a `RouteID` string.
- [ ] Keep a separate durable model identity type for persisted/session/catalog - [ ] Keep a separate durable model identity type for persisted/session/catalog
data, likely `{ providerID, modelID }`, and make it clear that it cannot data, likely `{ providerID, modelID }`, and make it clear that it cannot
execute without resolver context. execute without resolver context.
@@ -566,9 +566,9 @@ App boundary = explicit durable-config -> typed-provider call
- [x] Remove endpoint/auth escape hatches from route model selection; callers must - [x] Remove endpoint/auth escape hatches from route model selection; callers must
configure endpoint/auth through `route.with(...)` or provider facades before configure endpoint/auth through `route.with(...)` or provider facades before
calling `.model(...)`. calling `.model(...)`.
- [x] Remove request-shaping defaults from `Model`; selected models now carry only - [x] Remove request-shaping defaults from `LanguageModel`; selected models now carry only
id, provider, and configured route while defaults live on routes or requests. id, provider, and configured route while defaults live on routes or requests.
- [x] Rework `LLMClient.prepare` / `stream` / `generate` to read - [x] Rework `LLMClient.stream` / `generate` to read
`request.model.route` directly instead of calling `registeredRoute(...)`. `request.model.route` directly instead of calling `registeredRoute(...)`.
- [x] Remove `Route.make(...)` global registration from the normal execution - [x] Remove `Route.make(...)` global registration from the normal execution
path; keep route ids only as diagnostics/provider API labels. path; keep route ids only as diagnostics/provider API labels.
+2 -32
View File
@@ -50,18 +50,6 @@ const request = LLM.request({
}, },
}) })
// `http` is intentionally not needed for normal calls. This shows the shape for
// newly released provider fields before they deserve a typed provider option.
const rawOverlayExample = LLM.request({
model,
prompt: "Show the final HTTP overlay shape.",
http: {
body: { metadata: { example: "tutorial" } },
headers: { "x-opencode-tutorial": "1" },
query: { debug: "1" },
},
})
// 3. `generate` sends the request and collects the event stream into one // 3. `generate` sends the request and collects the event stream into one
// response object. `response.text` is the collected text output. // response object. `response.text` is the collected text output.
const generateOnce = Effect.gen(function* () { const generateOnce = Effect.gen(function* () {
@@ -222,33 +210,15 @@ const FakeEcho = {
}), }),
} }
// `LLMClient.prepare` is the lower-level inspection hook: it compiles through
// body conversion, validation, endpoint, auth, and HTTP construction without
// sending anything over the network.
const inspectFakeProvider = Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
model: FakeEcho.configure().model("tiny-echo"),
prompt: "Show me the provider pipeline.",
}),
)
console.log("\n== fake provider prepare ==")
console.log("route:", prepared.route)
console.log("body:", Formatter.formatJson(prepared.body, { space: 2 }))
})
// Provide the LLM runtime and the HTTP request executor once. Keep one path // Provide the LLM runtime and the HTTP request executor once. Keep one path
// enabled at a time so the tutorial can demonstrate generate, prepare, stream, // enabled at a time so the tutorial can demonstrate generate, stream, or
// or tool-loop behavior without spending tokens on every example. // tool-loop behavior without spending tokens on every example.
const requestExecutorLayer = RequestExecutor.fetchLayer const requestExecutorLayer = RequestExecutor.fetchLayer
const llmDeps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer) const llmDeps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer)
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(llmDeps)) const llmClientLayer = LLMClient.layer.pipe(Layer.provide(llmDeps))
const program = Effect.gen(function* () { const program = Effect.gen(function* () {
// yield* generateOnce // yield* generateOnce
// yield* inspectFakeProvider
// yield* LLMClient.prepare(rawOverlayExample).pipe(Effect.andThen((prepared) => Effect.sync(() => console.log(prepared.body))))
// yield* streamText // yield* streamText
// yield* generateStructuredObject // yield* generateStructuredObject
// yield* generateDynamicObject.pipe(Effect.andThen((response) => Effect.sync(() => console.log(response.object)))) // yield* generateDynamicObject.pipe(Effect.andThen((response) => Effect.sync(() => console.log(response.object))))
+2 -1
View File
@@ -38,7 +38,7 @@ const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
// Protocols whose wire format ignores inline cache markers (OpenAI's implicit // Protocols whose wire format ignores inline cache markers (OpenAI's implicit
// prefix caching, Gemini's implicit + out-of-band CachedContent). Skip the // prefix caching, Gemini's implicit + out-of-band CachedContent). Skip the
// whole policy pass for these — emitting hints would be harmless but pointless. // whole policy pass for these — emitting hints would be harmless but pointless.
const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse"]) const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse", "openrouter"])
const makeHint = (ttlSeconds: number | undefined): CacheHint => const makeHint = (ttlSeconds: number | undefined): CacheHint =>
ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" }) ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" })
@@ -133,6 +133,7 @@ const countHints = (request: LLMRequest) =>
export const applyCachePolicy = (request: LLMRequest): LLMRequest => { export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request
if (request.model.route.id === "openrouter" && (request.cache === undefined || request.cache === "auto")) return request
const policy = resolve(request.cache) const policy = resolve(request.cache)
if (!policy.tools && !policy.system && !policy.messages) return request if (!policy.tools && !policy.system && !policy.messages) return request
+4 -4
View File
@@ -1,25 +1,25 @@
import { Context, Effect, Layer } from "effect" import { Context, Effect, Layer } from "effect"
import { RequestExecutor } from "./route/executor" import { RequestExecutor } from "./route/executor"
import type { ImageOptions, ImageRequest, ImageRequestFor, ImageResponse } from "./image" import type { ImageOptions, ImageRequest, ImageRequestFor, ImageResponse } from "./image"
import type { LLMError } from "./schema" import type { AIError } from "./schema"
export type Execute = RequestExecutor.Interface["execute"] export type Execute = RequestExecutor.Interface["execute"]
export interface Interface { export interface Interface {
readonly generate: <Options extends ImageOptions>( readonly generate: <Options extends ImageOptions>(
request: ImageRequestFor<Options>, request: ImageRequestFor<Options>,
) => Effect.Effect<ImageResponse, LLMError> ) => Effect.Effect<ImageResponse, AIError>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/ImageClient") {} export class Service extends Context.Service<Service, Interface>()("@opencode/ImageClient") {}
export const generate = <Options extends ImageOptions>( export const generate = <Options extends ImageOptions>(
request: ImageRequestFor<Options>, request: ImageRequestFor<Options>,
): Effect.Effect<ImageResponse, LLMError> => ): Effect.Effect<ImageResponse, AIError> =>
Effect.gen(function* () { Effect.gen(function* () {
const client = yield* Service const client = yield* Service
return yield* client.generate(request) return yield* client.generate(request)
}) as Effect.Effect<ImageResponse, LLMError> }) as Effect.Effect<ImageResponse, AIError>
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect( export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
Service, Service,
+5 -8
View File
@@ -1,13 +1,10 @@
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import { HttpOptions, InvalidRequestReason, LLMError, ModelID, ProviderID, ProviderMetadata, Usage } from "./schema" import { HttpOptions, InvalidRequestReason, AIError, ModelID, ProviderID, ProviderMetadata, Usage } from "./schema"
import { ImageClient, Service, type Execute as ImageExecute } from "./image-client" import { ImageClient, Service, type Execute as ImageExecute } from "./image-client"
export interface ImageRoute<Options extends ImageOptions = ImageOptions> { export interface ImageRoute<Options extends ImageOptions = ImageOptions> {
readonly id: string readonly id: string
readonly generate: ( readonly generate: (request: ImageRequestFor<Options>, execute: ImageExecute) => Effect.Effect<ImageResponse, AIError>
request: ImageRequestFor<Options>,
execute: ImageExecute,
) => Effect.Effect<ImageResponse, LLMError>
} }
export type ImageOptions = Record<string, unknown> export type ImageOptions = Record<string, unknown>
@@ -146,13 +143,13 @@ export function request(input: ImageRequest | ImageRequestInput) {
export function generate<const Model extends object>( export function generate<const Model extends object>(
input: ImageRequestInput<Model>, input: ImageRequestInput<Model>,
): Effect.Effect<ImageResponse, LLMError, Service> ): Effect.Effect<ImageResponse, AIError, Service>
export function generate(input: ImageRequest): Effect.Effect<ImageResponse, LLMError, Service> export function generate(input: ImageRequest): Effect.Effect<ImageResponse, AIError, Service>
export function generate(input: ImageRequest | ImageRequestInput) { export function generate(input: ImageRequest | ImageRequestInput) {
return Effect.try({ return Effect.try({
try: () => (input instanceof ImageRequest ? input : request(input)), try: () => (input instanceof ImageRequest ? input : request(input)),
catch: (error) => catch: (error) =>
new LLMError({ new AIError({
module: "Image", module: "Image",
method: "generate", method: "generate",
reason: new InvalidRequestReason({ message: error instanceof Error ? error.message : String(error) }), reason: new InvalidRequestReason({ message: error instanceof Error ? error.message : String(error) }),
+4 -4
View File
@@ -5,8 +5,8 @@ export { Provider } from "./provider"
export { ProviderPackage } from "./provider-package" export { ProviderPackage } from "./provider-package"
export { isContextOverflow, isContextOverflowFailure } from "./provider-error" export { isContextOverflow, isContextOverflowFailure } from "./provider-error"
export type { export type {
RouteModelInput, RouteLanguageModelInput,
RouteRoutedModelInput, RouteRoutedLanguageModelInput,
Interface as LLMClientShape, Interface as LLMClientShape,
Service as LLMClientService, Service as LLMClientService,
} from "./route/client" } from "./route/client"
@@ -33,7 +33,7 @@ export type {
export * as LLM from "./llm" export * as LLM from "./llm"
export type { export type {
Definition as ProviderDefinition, Definition as ProviderDefinition,
ModelFactory as ProviderModelFactory, LanguageModelFactory as ProviderLanguageModelFactory,
ModelOptions as ProviderModelOptions, LanguageModelOptions as ProviderLanguageModelOptions,
} from "./provider" } from "./provider"
export type { Definition as ProviderPackageDefinition, Settings as ProviderPackageSettings } from "./provider-package" export type { Definition as ProviderPackageDefinition, Settings as ProviderPackageSettings } from "./provider-package"
+29 -17
View File
@@ -4,30 +4,33 @@ import {
GenerationOptions, GenerationOptions,
HttpOptions, HttpOptions,
InvalidProviderOutputReason, InvalidProviderOutputReason,
LLMError, AIError,
LLMEvent, LLMEvent,
LLMRequest, LLMRequest,
LLMResponse, LLMResponse,
Message, Message,
LanguageModel,
SystemPart, SystemPart,
ToolChoice, ToolChoice,
ToolDefinition, ToolDefinition,
type ContentPart, type ContentPart,
type LanguageModelProviderOptions,
} from "./schema" } from "./schema"
import { make as makeTool, toDefinitions, type ToolSchema } from "./tool" import { make as makeTool, toDefinitions, type ToolSchema } from "./tool"
/** Input accepted by `LLM.request`, normalized into the canonical `LLMRequest` class. */ /** Input accepted by `LLM.request`, normalized into the canonical `LLMRequest` class. */
export type RequestInput = Omit< export type RequestInput<SelectedLanguageModel extends LanguageModel = LanguageModel> = Omit<
ConstructorParameters<typeof LLMRequest>[0], ConstructorParameters<typeof LLMRequest>[0],
"system" | "messages" | "tools" | "toolChoice" | "generation" | "http" | "providerOptions" "model" | "system" | "messages" | "tools" | "toolChoice" | "generation" | "http" | "providerOptions"
> & { > & {
readonly model: SelectedLanguageModel
readonly system?: string | SystemPart | ReadonlyArray<SystemPart> readonly system?: string | SystemPart | ReadonlyArray<SystemPart>
readonly prompt?: string | ContentPart | ReadonlyArray<ContentPart> readonly prompt?: string | ContentPart | ReadonlyArray<ContentPart>
readonly messages?: ReadonlyArray<Message | Message.Input> readonly messages?: ReadonlyArray<Message | Message.Input>
readonly tools?: ReadonlyArray<ToolDefinition.Input> readonly tools?: ReadonlyArray<ToolDefinition.Input>
readonly toolChoice?: ToolChoice.Input readonly toolChoice?: ToolChoice.Input
readonly generation?: GenerationOptions.Input readonly generation?: GenerationOptions.Input
readonly providerOptions?: ConstructorParameters<typeof LLMRequest>[0]["providerOptions"] readonly providerOptions?: NoInfer<LanguageModelProviderOptions<SelectedLanguageModel>>
readonly http?: HttpOptions.Input readonly http?: HttpOptions.Input
} }
@@ -35,7 +38,9 @@ export const generate = LLMClient.generate
export const stream = LLMClient.stream export const stream = LLMClient.stream
export const request = (input: RequestInput) => { export const request = <const SelectedLanguageModel extends LanguageModel>(
input: RequestInput<SelectedLanguageModel>,
) => {
const { const {
system: requestSystem, system: requestSystem,
prompt, prompt,
@@ -63,7 +68,10 @@ const GENERATE_OBJECT_TOOL_NAME = "generate_object"
const GENERATE_OBJECT_TOOL_DESCRIPTION = "Return the structured result by calling this tool." const GENERATE_OBJECT_TOOL_DESCRIPTION = "Return the structured result by calling this tool."
type GenerateObjectBase = Omit<RequestInput, "tools" | "toolChoice"> type GenerateObjectBase<SelectedLanguageModel extends LanguageModel = LanguageModel> = Omit<
RequestInput<SelectedLanguageModel>,
"tools" | "toolChoice"
>
export class GenerateObjectResponse<T> { export class GenerateObjectResponse<T> {
constructor( constructor(
@@ -80,11 +88,15 @@ export class GenerateObjectResponse<T> {
} }
} }
export interface GenerateObjectOptions<S extends ToolSchema<any>> extends GenerateObjectBase { export interface GenerateObjectOptions<
S extends ToolSchema<any>,
SelectedLanguageModel extends LanguageModel = LanguageModel,
> extends GenerateObjectBase<SelectedLanguageModel> {
readonly schema: S readonly schema: S
} }
export interface GenerateObjectDynamicOptions extends GenerateObjectBase { export interface GenerateObjectDynamicOptions<SelectedLanguageModel extends LanguageModel = LanguageModel>
extends GenerateObjectBase<SelectedLanguageModel> {
/** Raw JSON Schema object describing the expected output shape. */ /** Raw JSON Schema object describing the expected output shape. */
readonly jsonSchema: JsonSchema.JsonSchema readonly jsonSchema: JsonSchema.JsonSchema
} }
@@ -103,7 +115,7 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* (
(event) => LLMEvent.is.toolCall(event) && event.name === GENERATE_OBJECT_TOOL_NAME, (event) => LLMEvent.is.toolCall(event) && event.name === GENERATE_OBJECT_TOOL_NAME,
) )
if (!call || !LLMEvent.is.toolCall(call)) if (!call || !LLMEvent.is.toolCall(call))
return yield* new LLMError({ return yield* new AIError({
module: "LLM", module: "LLM",
method: "generateObject", method: "generateObject",
reason: new InvalidProviderOutputReason({ reason: new InvalidProviderOutputReason({
@@ -113,7 +125,7 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* (
const object = yield* tool._decode(call.input).pipe( const object = yield* tool._decode(call.input).pipe(
Effect.mapError( Effect.mapError(
(error) => (error) =>
new LLMError({ new AIError({
module: "LLM", module: "LLM",
method: "generateObject", method: "generateObject",
reason: new InvalidProviderOutputReason({ reason: new InvalidProviderOutputReason({
@@ -133,16 +145,16 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* (
* Two input modes: * Two input modes:
* *
* 1. `schema: EffectSchema<T>` — `.object` is decoded and typed as `T`. * 1. `schema: EffectSchema<T>` — `.object` is decoded and typed as `T`.
* Decode failures surface as `LLMError`. * Decode failures surface as `AIError`.
* 2. `jsonSchema: JsonSchema.JsonSchema` — `.object` is `unknown`. Use when * 2. `jsonSchema: JsonSchema.JsonSchema` — `.object` is `unknown`. Use when
* the schema is only available at runtime (MCP, plugin manifests). Caller validates. * the schema is only available at runtime (MCP, plugin manifests). Caller validates.
*/ */
export function generateObject<S extends ToolSchema<any>>( export function generateObject<const SelectedLanguageModel extends LanguageModel, S extends ToolSchema<any>>(
options: GenerateObjectOptions<S>, options: GenerateObjectOptions<S, SelectedLanguageModel>,
): Effect.Effect<GenerateObjectResponse<Schema.Schema.Type<S>>, LLMError> ): Effect.Effect<GenerateObjectResponse<Schema.Schema.Type<S>>, AIError>
export function generateObject( export function generateObject<const SelectedLanguageModel extends LanguageModel>(
options: GenerateObjectDynamicOptions, options: GenerateObjectDynamicOptions<SelectedLanguageModel>,
): Effect.Effect<GenerateObjectResponse<unknown>, LLMError> ): Effect.Effect<GenerateObjectResponse<unknown>, AIError>
export function generateObject(options: GenerateObjectOptions<ToolSchema<any>> | GenerateObjectDynamicOptions) { export function generateObject(options: GenerateObjectOptions<ToolSchema<any>> | GenerateObjectDynamicOptions) {
if ("schema" in options) { if ("schema" in options) {
const { schema, ...rest } = options const { schema, ...rest } = options
+11 -13
View File
@@ -6,7 +6,7 @@ import { Endpoint } from "../route/endpoint"
import { Framing } from "../route/framing" import { Framing } from "../route/framing"
import { Protocol } from "../route/protocol" import { Protocol } from "../route/protocol"
import { import {
LLMError, AIError,
LLMEvent, LLMEvent,
mergeJsonRecords, mergeJsonRecords,
Usage, Usage,
@@ -242,16 +242,14 @@ const AnthropicUsage = Schema.StructWithRest(
cache_creation_input_tokens: optionalNull(Schema.Number), cache_creation_input_tokens: optionalNull(Schema.Number),
cache_read_input_tokens: optionalNull(Schema.Number), cache_read_input_tokens: optionalNull(Schema.Number),
server_tool_use: optionalNull( server_tool_use: optionalNull(
Schema.StructWithRest( Schema.StructWithRest(Schema.Struct({ web_search_requests: Schema.optional(Schema.Number) }), [
Schema.Struct({ web_search_requests: Schema.optional(Schema.Number) }), Schema.Record(Schema.String, Schema.Unknown),
[Schema.Record(Schema.String, Schema.Unknown)], ]),
),
), ),
output_tokens_details: optionalNull( output_tokens_details: optionalNull(
Schema.StructWithRest( Schema.StructWithRest(Schema.Struct({ thinking_tokens: Schema.optional(Schema.Number) }), [
Schema.Struct({ thinking_tokens: Schema.optional(Schema.Number) }), Schema.Record(Schema.String, Schema.Unknown),
[Schema.Record(Schema.String, Schema.Unknown)], ]),
),
), ),
}), }),
[Schema.Record(Schema.String, Schema.Unknown)], [Schema.Record(Schema.String, Schema.Unknown)],
@@ -725,8 +723,7 @@ const mergeUsage = (left: Usage | undefined, right: Usage | undefined) => {
reasoningTokens, reasoningTokens,
totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined), totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined),
providerMetadata: { providerMetadata: {
anthropic: anthropic: mergeJsonRecords(left.providerMetadata?.["anthropic"], right.providerMetadata?.["anthropic"]) ?? {},
mergeJsonRecords(left.providerMetadata?.["anthropic"], right.providerMetadata?.["anthropic"]) ?? {},
}, },
}) })
} }
@@ -816,7 +813,8 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
if (block.type === "thinking" && block.thinking !== undefined) { if (block.type === "thinking" && block.thinking !== undefined) {
const events: LLMEvent[] = [] const events: LLMEvent[] = []
const id = `reasoning-${event.index ?? 0}` const id = `reasoning-${event.index ?? 0}`
const providerMetadata = block.signature === undefined ? undefined : anthropicMetadata({ signature: block.signature }) const providerMetadata =
block.signature === undefined ? undefined : anthropicMetadata({ signature: block.signature })
const lifecycle = Lifecycle.reasoningStart(state.lifecycle, events, id, providerMetadata) const lifecycle = Lifecycle.reasoningStart(state.lifecycle, events, id, providerMetadata)
return [ return [
{ {
@@ -980,7 +978,7 @@ const providerErrorMessage = (event: AnthropicEvent): string => {
} }
const onError = (event: AnthropicEvent) => const onError = (event: AnthropicEvent) =>
new LLMError({ new AIError({
module: ADAPTER, module: ADAPTER,
method: "stream", method: "stream",
reason: classifyProviderFailure({ message: providerErrorMessage(event), code: event.error?.type }), reason: classifyProviderFailure({ message: providerErrorMessage(event), code: event.error?.type }),
@@ -3,7 +3,7 @@ import { Route } from "../route/client"
import { Endpoint } from "../route/endpoint" import { Endpoint } from "../route/endpoint"
import { Protocol } from "../route/protocol" import { Protocol } from "../route/protocol"
import { import {
LLMError, AIError,
LLMEvent, LLMEvent,
Usage, Usage,
type CacheHint, type CacheHint,
@@ -11,7 +11,7 @@ import {
type FinishReasonDetails, type FinishReasonDetails,
type JsonSchema, type JsonSchema,
type LLMRequest, type LLMRequest,
type ModelToolSchemaCompatibility, type LanguageModelToolSchemaCompatibility,
type ProviderMetadata, type ProviderMetadata,
type ReasoningPart, type ReasoningPart,
type ToolCallPart, type ToolCallPart,
@@ -232,7 +232,7 @@ const lowerToolSpec = (tool: ToolDefinition, inputSchema: JsonSchema): BedrockTo
}) })
const lowerTools = ( const lowerTools = (
compatibility: ModelToolSchemaCompatibility | undefined, compatibility: LanguageModelToolSchemaCompatibility | undefined,
breakpoints: BedrockCache.Breakpoints, breakpoints: BedrockCache.Breakpoints,
tools: ReadonlyArray<ToolDefinition>, tools: ReadonlyArray<ToolDefinition>,
): BedrockTool[] => { ): BedrockTool[] => {
@@ -274,9 +274,7 @@ const reasoningSignature = (part: ReasoningPart) => {
const reasoningRedactedData = (part: ReasoningPart) => { const reasoningRedactedData = (part: ReasoningPart) => {
const bedrock = part.providerMetadata?.bedrock const bedrock = part.providerMetadata?.bedrock
return ProviderShared.isRecord(bedrock) && typeof bedrock.redactedData === "string" return ProviderShared.isRecord(bedrock) && typeof bedrock.redactedData === "string" ? bedrock.redactedData : undefined
? bedrock.redactedData
: undefined
} }
const lowerToolCall = (part: ToolCallPart): BedrockToolUseBlock => ({ const lowerToolCall = (part: ToolCallPart): BedrockToolUseBlock => ({
@@ -656,7 +654,7 @@ const step = (state: ParserState, event: BedrockEvent) =>
] as const ] as const
).find((entry) => entry[1] !== undefined) ).find((entry) => entry[1] !== undefined)
if (exception) { if (exception) {
return yield* new LLMError({ return yield* new AIError({
module: ADAPTER, module: ADAPTER,
method: "stream", method: "stream",
reason: classifyProviderFailure({ reason: classifyProviderFailure({
+58 -4
View File
@@ -29,9 +29,30 @@ export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1bet
export interface OptionsInput { export interface OptionsInput {
readonly [key: string]: unknown readonly [key: string]: unknown
readonly cachedContent?: string
readonly safetySettings?: ReadonlyArray<{
readonly category:
| "HARM_CATEGORY_UNSPECIFIED"
| "HARM_CATEGORY_HATE_SPEECH"
| "HARM_CATEGORY_DANGEROUS_CONTENT"
| "HARM_CATEGORY_HARASSMENT"
| "HARM_CATEGORY_SEXUALLY_EXPLICIT"
| "HARM_CATEGORY_CIVIC_INTEGRITY"
| (string & {})
readonly threshold:
| "HARM_BLOCK_THRESHOLD_UNSPECIFIED"
| "BLOCK_LOW_AND_ABOVE"
| "BLOCK_MEDIUM_AND_ABOVE"
| "BLOCK_ONLY_HIGH"
| "BLOCK_NONE"
| "OFF"
| (string & {})
}>
readonly serviceTier?: "standard" | "flex" | "priority" | (string & {})
readonly thinkingConfig?: { readonly thinkingConfig?: {
readonly thinkingBudget?: number readonly thinkingBudget?: number
readonly includeThoughts?: boolean readonly includeThoughts?: boolean
readonly thinkingLevel?: "minimal" | "low" | "medium" | "high" | (string & {})
} }
} }
@@ -111,6 +132,12 @@ const GeminiToolConfig = Schema.Struct({
const GeminiThinkingConfig = Schema.Struct({ const GeminiThinkingConfig = Schema.Struct({
thinkingBudget: Schema.optional(Schema.Number), thinkingBudget: Schema.optional(Schema.Number),
includeThoughts: Schema.optional(Schema.Boolean), includeThoughts: Schema.optional(Schema.Boolean),
thinkingLevel: Schema.optional(Schema.String),
})
const GeminiSafetySetting = Schema.Struct({
category: Schema.String,
threshold: Schema.String,
}) })
const GeminiGenerationConfig = Schema.Struct({ const GeminiGenerationConfig = Schema.Struct({
@@ -123,7 +150,10 @@ const GeminiGenerationConfig = Schema.Struct({
}) })
const GeminiBodyFields = { const GeminiBodyFields = {
cachedContent: Schema.optional(Schema.String),
contents: Schema.Array(GeminiContent), contents: Schema.Array(GeminiContent),
safetySettings: optionalArray(GeminiSafetySetting),
serviceTier: Schema.optional(Schema.String),
systemInstruction: Schema.optional(GeminiSystemInstruction), systemInstruction: Schema.optional(GeminiSystemInstruction),
tools: optionalArray(GeminiTool), tools: optionalArray(GeminiTool),
toolConfig: Schema.optional(GeminiToolConfig), toolConfig: Schema.optional(GeminiToolConfig),
@@ -316,17 +346,38 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
}) })
const resolveOptions = (request: LLMRequest) => { const resolveOptions = (request: LLMRequest) => {
const value = request.providerOptions?.gemini?.thinkingConfig const input = request.providerOptions?.gemini
if (!ProviderShared.isRecord(value)) return {} const value = input?.thinkingConfig
const thinkingConfig = { const thinkingConfig = {
thinkingBudget: typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined, thinkingBudget:
includeThoughts: typeof value.includeThoughts === "boolean" ? value.includeThoughts : undefined, ProviderShared.isRecord(value) && typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined,
includeThoughts:
ProviderShared.isRecord(value) && typeof value.includeThoughts === "boolean"
? value.includeThoughts
: ProviderShared.isRecord(value)
? true
: undefined,
thinkingLevel:
ProviderShared.isRecord(value) && typeof value.thinkingLevel === "string" ? value.thinkingLevel : undefined,
} }
return { return {
cachedContent: typeof input?.cachedContent === "string" ? input.cachedContent : undefined,
safetySettings: mapSafetySettings(input?.safetySettings),
serviceTier: typeof input?.serviceTier === "string" ? input.serviceTier : undefined,
thinkingConfig: Object.values(thinkingConfig).some((item) => item !== undefined) ? thinkingConfig : undefined, thinkingConfig: Object.values(thinkingConfig).some((item) => item !== undefined) ? thinkingConfig : undefined,
} }
} }
function mapSafetySettings(value: unknown) {
if (!Array.isArray(value)) return undefined
const settings = value.flatMap((item) =>
ProviderShared.isRecord(item) && typeof item.category === "string" && typeof item.threshold === "string"
? [{ category: item.category, threshold: item.threshold }]
: [],
)
return settings
}
const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) { const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) {
const hasTools = request.tools.length > 0 const hasTools = request.tools.length > 0
const generation = request.generation const generation = request.generation
@@ -342,7 +393,10 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
} }
return { return {
cachedContent: options.cachedContent,
contents: yield* lowerMessages(request), contents: yield* lowerMessages(request),
safetySettings: options.safetySettings,
serviceTier: options.serviceTier,
systemInstruction: systemInstruction:
request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] }, request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] },
tools: hasTools tools: hasTools
+3 -3
View File
@@ -11,7 +11,7 @@ import {
import { Auth, type Definition as AuthDefinition } from "../route/auth" import { Auth, type Definition as AuthDefinition } from "../route/auth"
import { import {
InvalidProviderOutputReason, InvalidProviderOutputReason,
LLMError, AIError,
Usage, Usage,
mergeHttpOptions, mergeHttpOptions,
mergeJsonRecords, mergeJsonRecords,
@@ -125,7 +125,7 @@ const nativeOptions = (options: GoogleImageOptions | undefined) => {
} }
const invalidOutput = (message: string, providerMetadata?: ProviderMetadata) => const invalidOutput = (message: string, providerMetadata?: ProviderMetadata) =>
new LLMError({ new AIError({
module: ADAPTER, module: ADAPTER,
method: "generate", method: "generate",
reason: new InvalidProviderOutputReason({ message, route: ADAPTER, providerMetadata }), reason: new InvalidProviderOutputReason({ message, route: ADAPTER, providerMetadata }),
@@ -285,7 +285,7 @@ export const model = (input: ModelInput) => {
return ImageModel.make<GoogleImageOptions>({ id: input.id, provider: "google", route, http: input.http }) return ImageModel.make<GoogleImageOptions>({ id: input.id, provider: "google", route, http: input.http })
} }
const googleImagePart = (image: ImageInput): Effect.Effect<Record<string, unknown>, LLMError> => { const googleImagePart = (image: ImageInput): Effect.Effect<Record<string, unknown>, AIError> => {
if (image.type === "bytes") if (image.type === "bytes")
return Effect.succeed({ inlineData: { mimeType: image.mediaType, data: Encoding.encodeBase64(image.data) } }) return Effect.succeed({ inlineData: { mimeType: image.mediaType, data: Encoding.encodeBase64(image.data) } })
if (image.type === "file-uri") return Effect.succeed({ fileData: { mimeType: image.mediaType, fileUri: image.uri } }) if (image.type === "file-uri") return Effect.succeed({ fileData: { mimeType: image.mediaType, fileUri: image.uri } })
+4 -10
View File
@@ -3,7 +3,7 @@ import type { Content } from "@opencode-ai/schema/tool"
import { HttpTransport } from "../route/transport" import { HttpTransport } from "../route/transport"
import { Protocol } from "../route/protocol" import { Protocol } from "../route/protocol"
import { import {
LLMError, AIError,
LLMEvent, LLMEvent,
Usage, Usage,
type FinishReason, type FinishReason,
@@ -434,10 +434,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
const groups = content.reduce<Array<{ phase: MessagePhase | null | undefined; parts: TextPart[] }>>( const groups = content.reduce<Array<{ phase: MessagePhase | null | undefined; parts: TextPart[] }>>(
(groups, part) => { (groups, part) => {
const metadata = part.providerMetadata?.[providerMetadataKey] const metadata = part.providerMetadata?.[providerMetadataKey]
const phase = const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase, extension) : undefined
ProviderShared.isRecord(metadata)
? messagePhase(metadata.phase, extension)
: undefined
const group = groups.at(-1) const group = groups.at(-1)
if (group && group.phase === phase) group.parts.push(part) if (group && group.phase === phase) group.parts.push(part)
else groups.push({ phase, parts: [part] }) else groups.push({ phase, parts: [part] })
@@ -646,10 +643,7 @@ const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepRe
const phase = state.messagePhases[id] const phase = state.messagePhases[id]
const metadata = phase === undefined ? undefined : providerMetadata(state, { phase }) const metadata = phase === undefined ? undefined : providerMetadata(state, { phase })
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata) const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata)
return [ return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) }, events]
{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) },
events,
]
} }
const onOutputTextDone = (state: ParserState, event: Event, id: string): StepResult => { const onOutputTextDone = (state: ParserState, event: Event, id: string): StepResult => {
@@ -975,7 +969,7 @@ const providerErrorMessage = (event: Event, fallback: string): string => {
const providerError = (state: ParserState, event: Event, fallback: string) => { const providerError = (state: ParserState, event: Event, fallback: string) => {
const code = event.code || event.error?.code || event.response?.error?.code || undefined const code = event.code || event.error?.code || event.response?.error?.code || undefined
const message = providerErrorMessage(event, fallback) const message = providerErrorMessage(event, fallback)
return new LLMError({ return new AIError({
module: state.id, module: state.id,
method: "stream", method: "stream",
reason: classifyProviderFailure({ message, code }), reason: classifyProviderFailure({ message, code }),
+128 -29
View File
@@ -6,11 +6,12 @@ import { Endpoint } from "../route/endpoint"
import { HttpTransport } from "../route/transport" import { HttpTransport } from "../route/transport"
import { Protocol } from "../route/protocol" import { Protocol } from "../route/protocol"
import { import {
LLMError, AIError,
LLMEvent, LLMEvent,
Usage, Usage,
type FinishReason, type FinishReason,
type FinishReasonDetails, type FinishReasonDetails,
type CacheHint,
type JsonSchema, type JsonSchema,
type LLMRequest, type LLMRequest,
type MediaPart, type MediaPart,
@@ -38,6 +39,11 @@ export const PATH = "/chat/completions"
// The body schema is the provider-native JSON body. `fromRequest` below builds // The body schema is the provider-native JSON body. `fromRequest` below builds
// this shape from the common `LLMRequest`, then `Route.make` validates and // this shape from the common `LLMRequest`, then `Route.make` validates and
// JSON-encodes it before transport. // JSON-encodes it before transport.
const OpenAIChatCacheControl = Schema.Struct({
type: Schema.Literal("ephemeral"),
ttl: Schema.optional(Schema.String),
})
const OpenAIChatFunction = Schema.Struct({ const OpenAIChatFunction = Schema.Struct({
name: Schema.String, name: Schema.String,
description: Schema.String, description: Schema.String,
@@ -47,6 +53,7 @@ const OpenAIChatFunction = Schema.Struct({
const OpenAIChatTool = Schema.Struct({ const OpenAIChatTool = Schema.Struct({
type: Schema.tag("function"), type: Schema.tag("function"),
function: OpenAIChatFunction, function: OpenAIChatFunction,
cache_control: Schema.optional(OpenAIChatCacheControl),
}) })
type OpenAIChatTool = Schema.Schema.Type<typeof OpenAIChatTool> type OpenAIChatTool = Schema.Schema.Type<typeof OpenAIChatTool>
@@ -61,7 +68,11 @@ const OpenAIChatAssistantToolCall = Schema.Struct({
type OpenAIChatAssistantToolCall = Schema.Schema.Type<typeof OpenAIChatAssistantToolCall> type OpenAIChatAssistantToolCall = Schema.Schema.Type<typeof OpenAIChatAssistantToolCall>
const OpenAIChatUserContent = Schema.Union([ const OpenAIChatUserContent = Schema.Union([
Schema.Struct({ type: Schema.Literal("text"), text: Schema.String }), Schema.Struct({
type: Schema.Literal("text"),
text: Schema.String,
cache_control: Schema.optional(OpenAIChatCacheControl),
}),
Schema.Struct({ Schema.Struct({
type: Schema.Literal("image_url"), type: Schema.Literal("image_url"),
image_url: Schema.Struct({ url: Schema.String }), image_url: Schema.Struct({ url: Schema.String }),
@@ -69,7 +80,10 @@ const OpenAIChatUserContent = Schema.Union([
]) ])
const OpenAIChatMessage = Schema.Union([ const OpenAIChatMessage = Schema.Union([
Schema.Struct({ role: Schema.Literal("system"), content: Schema.String }), Schema.Struct({
role: Schema.Literal("system"),
content: Schema.Union([Schema.String, Schema.Array(OpenAIChatUserContent)]),
}),
Schema.Struct({ Schema.Struct({
role: Schema.Literal("user"), role: Schema.Literal("user"),
content: Schema.Union([Schema.String, Schema.Array(OpenAIChatUserContent)]), content: Schema.Union([Schema.String, Schema.Array(OpenAIChatUserContent)]),
@@ -83,10 +97,16 @@ const OpenAIChatMessage = Schema.Union([
reasoning: Schema.optional(Schema.String), reasoning: Schema.optional(Schema.String),
reasoning_text: Schema.optional(Schema.String), reasoning_text: Schema.optional(Schema.String),
reasoning_details: Schema.optional(Schema.Unknown), reasoning_details: Schema.optional(Schema.Unknown),
cache_control: Schema.optional(OpenAIChatCacheControl),
}), }),
[Schema.Record(Schema.String, Schema.Unknown)], [Schema.Record(Schema.String, Schema.Unknown)],
), ),
Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }), Schema.Struct({
role: Schema.Literal("tool"),
tool_call_id: Schema.String,
content: Schema.String,
cache_control: Schema.optional(OpenAIChatCacheControl),
}),
]).pipe(Schema.toTaggedUnion("role")) ]).pipe(Schema.toTaggedUnion("role"))
type OpenAIChatMessage = Schema.Schema.Type<typeof OpenAIChatMessage> type OpenAIChatMessage = Schema.Schema.Type<typeof OpenAIChatMessage>
@@ -107,6 +127,7 @@ export const bodyFields = {
stream_options: Schema.optional(Schema.Struct({ include_usage: Schema.Boolean })), stream_options: Schema.optional(Schema.Struct({ include_usage: Schema.Boolean })),
store: Schema.optional(Schema.Boolean), store: Schema.optional(Schema.Boolean),
reasoning_effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort), reasoning_effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
max_completion_tokens: Schema.optional(Schema.Number),
max_tokens: Schema.optional(Schema.Number), max_tokens: Schema.optional(Schema.Number),
temperature: Schema.optional(Schema.Number), temperature: Schema.optional(Schema.Number),
top_p: Schema.optional(Schema.Number), top_p: Schema.optional(Schema.Number),
@@ -209,13 +230,20 @@ export interface ParserState {
// Lowering is the only place that knows how common LLM messages map onto the // Lowering is the only place that knows how common LLM messages map onto the
// OpenAI Chat wire format. Keep provider quirks here instead of leaking native // OpenAI Chat wire format. Keep provider quirks here instead of leaking native
// fields into `LLMRequest`. // fields into `LLMRequest`.
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema): OpenAIChatTool => ({ interface LoweringOptions {
readonly cacheControl?: (
cache: CacheHint | undefined,
) => Schema.Schema.Type<typeof OpenAIChatCacheControl> | undefined
}
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema, options: LoweringOptions): OpenAIChatTool => ({
type: "function", type: "function",
function: { function: {
name: tool.name, name: tool.name,
description: tool.description, description: tool.description,
parameters: ToolSchemaProjection.openAI(inputSchema), parameters: ToolSchemaProjection.openAI(inputSchema),
}, },
cache_control: options.cacheControl?.(tool.cache),
}) })
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) => const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
@@ -257,11 +285,14 @@ const reasoningDetails = (parts: ReadonlyArray<ReasoningPart>, native: unknown)
if (isRecord(native) && Array.isArray(native.reasoning_details)) return native.reasoning_details if (isRecord(native) && Array.isArray(native.reasoning_details)) return native.reasoning_details
} }
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (message: OpenAIChatRequestMessage) { const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (
message: OpenAIChatRequestMessage,
options: LoweringOptions,
) {
const content: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = [] const content: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
for (const part of message.content) { for (const part of message.content) {
if (part.type === "text") { if (part.type === "text") {
content.push({ type: "text", text: part.text }) content.push({ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) })
continue continue
} }
if (part.type === "media") { if (part.type === "media") {
@@ -270,14 +301,18 @@ const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (mes
} }
return yield* ProviderShared.unsupportedContent("OpenAI Chat", "user", ["text", "media"]) return yield* ProviderShared.unsupportedContent("OpenAI Chat", "user", ["text", "media"])
} }
if (content.every((part) => part.type === "text")) if (content.every((part) => part.type === "text" && part.cache_control === undefined))
return { role: "user" as const, content: content.map((part) => part.text).join("") } return {
role: "user" as const,
content: content.map((part) => (part.type === "text" ? part.text : "")).join(""),
}
return { role: "user" as const, content } return { role: "user" as const, content }
}) })
const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(function* ( const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(function* (
message: OpenAIChatRequestMessage, message: OpenAIChatRequestMessage,
configuredField?: string, configuredField?: string,
options: LoweringOptions = {},
) { ) {
const content: TextPart[] = [] const content: TextPart[] = []
const reasoning: ReasoningPart[] = [] const reasoning: ReasoningPart[] = []
@@ -315,29 +350,44 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
if (reasoning.length === 0) return nativeReasoning if (reasoning.length === 0) return nativeReasoning
return text return text
})() })()
const cached = message.content.findLast((part) => "cache" in part && part.cache !== undefined)
const result = { const result = {
role: "assistant" as const, role: "assistant" as const,
content: content.length === 0 ? null : ProviderShared.joinText(content), content: content.length === 0 ? null : ProviderShared.joinText(content),
tool_calls: toolCalls.length === 0 ? undefined : toolCalls, tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
reasoning_details: details, reasoning_details: details,
cache_control: options.cacheControl?.(cached && "cache" in cached ? cached.cache : undefined),
} }
if (field === undefined || reasoningText === undefined) return result if (field === undefined || reasoningText === undefined) return result
return { ...result, [field]: reasoningText } return { ...result, [field]: reasoningText }
}) })
const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (message: OpenAIChatRequestMessage) { const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
message: OpenAIChatRequestMessage,
options: LoweringOptions,
) {
const messages: OpenAIChatMessage[] = [] const messages: OpenAIChatMessage[] = []
const images: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = [] const images: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
for (const part of message.content) { for (const part of message.content) {
if (!ProviderShared.supportsContent(part, ["tool-result"])) if (!ProviderShared.supportsContent(part, ["tool-result"]))
return yield* ProviderShared.unsupportedContent("OpenAI Chat", "tool", ["tool-result"]) return yield* ProviderShared.unsupportedContent("OpenAI Chat", "tool", ["tool-result"])
if (part.result.type !== "content") { if (part.result.type !== "content") {
messages.push({ role: "tool", tool_call_id: part.id, content: ProviderShared.toolResultText(part) }) messages.push({
role: "tool",
tool_call_id: part.id,
content: ProviderShared.toolResultText(part),
cache_control: options.cacheControl?.(part.cache),
})
continue continue
} }
const content: ReadonlyArray<Tool.Content> = part.result.value const content: ReadonlyArray<Tool.Content> = part.result.value
const text = content.filter((item) => item.type === "text").map((item) => item.text) const text = content.filter((item) => item.type === "text").map((item) => item.text)
messages.push({ role: "tool", tool_call_id: part.id, content: text.join("\n") }) messages.push({
role: "tool",
tool_call_id: part.id,
content: text.join("\n"),
cache_control: options.cacheControl?.(part.cache),
})
const files = content.filter((item) => item.type === "file") const files = content.filter((item) => item.type === "file")
images.push( images.push(
...(yield* Effect.forEach(files, (item) => ...(yield* Effect.forEach(files, (item) =>
@@ -351,15 +401,29 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (m
const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* ( const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (
message: OpenAIChatRequestMessage, message: OpenAIChatRequestMessage,
reasoningField?: string, reasoningField?: string,
options: LoweringOptions = {},
) { ) {
if (message.role === "user") return [yield* lowerUserMessage(message)] if (message.role === "user") return [yield* lowerUserMessage(message, options)]
if (message.role === "assistant") return [yield* lowerAssistantMessage(message, reasoningField)] if (message.role === "assistant") return [yield* lowerAssistantMessage(message, reasoningField, options)]
return (yield* lowerToolMessages(message)).messages return (yield* lowerToolMessages(message, options)).messages
}) })
const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: LLMRequest) { const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: LLMRequest, options: LoweringOptions) {
const system: OpenAIChatMessage[] = const system: OpenAIChatMessage[] =
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }] request.system.length === 0
? []
: request.system.some((part) => part.cache !== undefined) && options.cacheControl !== undefined
? [
{
role: "system",
content: request.system.map((part) => ({
type: "text",
text: part.text,
cache_control: options.cacheControl?.(part.cache),
})),
},
]
: [{ role: "system", content: ProviderShared.joinText(request.system) }]
const messages = [...system] const messages = [...system]
const pendingImages: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = [] const pendingImages: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
const flushImages = () => { const flushImages = () => {
@@ -370,28 +434,53 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
if (message.role === "system") { if (message.role === "system") {
const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Chat", message) const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Chat", message)
if (pendingImages.length > 0) { if (pendingImages.length > 0) {
messages.push({ role: "user", content: [...pendingImages.splice(0), { type: "text", text: part.text }] }) messages.push({
role: "user",
content: [
...pendingImages.splice(0),
{ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) },
],
})
continue continue
} }
const previous = messages.at(-1) const previous = messages.at(-1)
if (previous?.role === "user" && typeof previous.content === "string") if (previous?.role === "user" && typeof previous.content === "string")
messages[messages.length - 1] = { role: "user", content: `${previous.content}\n${part.text}` } messages[messages.length - 1] = options.cacheControl?.(part.cache)
? {
role: "user",
content: [
{ type: "text", text: previous.content },
{ type: "text", text: part.text, cache_control: options.cacheControl(part.cache) },
],
}
: { role: "user", content: `${previous.content}\n${part.text}` }
else if (previous?.role === "user" && Array.isArray(previous.content)) else if (previous?.role === "user" && Array.isArray(previous.content))
messages[messages.length - 1] = { messages[messages.length - 1] = {
role: "user", role: "user",
content: [...previous.content, { type: "text", text: part.text }], content: [
...previous.content,
{ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) },
],
} }
else messages.push({ role: "user", content: part.text }) else
messages.push(
options.cacheControl?.(part.cache)
? {
role: "user",
content: [{ type: "text", text: part.text, cache_control: options.cacheControl(part.cache) }],
}
: { role: "user", content: part.text },
)
continue continue
} }
if (message.role === "tool") { if (message.role === "tool") {
const lowered = yield* lowerToolMessages(message) const lowered = yield* lowerToolMessages(message, options)
messages.push(...lowered.messages) messages.push(...lowered.messages)
pendingImages.push(...lowered.images) pendingImages.push(...lowered.images)
continue continue
} }
flushImages() flushImages()
messages.push(...(yield* lowerMessage(message, request.model.compatibility?.reasoningField))) messages.push(...(yield* lowerMessage(message, request.model.compatibility?.reasoningField, options)))
} }
flushImages() flushImages()
return messages return messages
@@ -405,7 +494,10 @@ const lowerOptions = (request: LLMRequest) => {
} }
} }
const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMRequest) { export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
request: LLMRequest,
options: LoweringOptions = {},
) {
// `fromRequest` returns the provider body only. Endpoint, auth, framing, // `fromRequest` returns the provider body only. Endpoint, auth, framing,
// validation, and HTTP execution are composed by `Route.make`. // validation, and HTTP execution are composed by `Route.make`.
const reasoningField = request.model.compatibility?.reasoningField const reasoningField = request.model.compatibility?.reasoningField
@@ -415,19 +507,26 @@ const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMR
) )
const generation = request.generation const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const maxTokensField = request.model.compatibility?.maxTokensField ?? "max_tokens"
return { return {
model: request.model.id, model: request.model.id,
messages: yield* lowerMessages(request), messages: yield* lowerMessages(request, options),
tools: tools:
request.tools.length === 0 request.tools.length === 0
? undefined ? undefined
: request.tools.map((tool) => : request.tools.map((tool) =>
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)), lowerTool(
tool,
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
options,
),
), ),
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined, tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
stream: true as const, stream: true as const,
stream_options: { include_usage: true }, stream_options: { include_usage: true },
max_tokens: generation?.maxTokens, ...(maxTokensField === "max_completion_tokens"
? { max_completion_tokens: generation?.maxTokens }
: { max_tokens: generation?.maxTokens }),
temperature: generation?.temperature, temperature: generation?.temperature,
top_p: generation?.topP, top_p: generation?.topP,
frequency_penalty: generation?.frequencyPenalty, frequency_penalty: generation?.frequencyPenalty,
@@ -456,7 +555,7 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
// OpenAI Chat reports `prompt_tokens` (inclusive total) with a // OpenAI Chat reports `prompt_tokens` (inclusive total) with a
// cached-read and cache-write subsets, and `completion_tokens` (inclusive // cached-read and cache-write subsets, and `completion_tokens` (inclusive
// total) with a `reasoning_tokens` subset. We pass the inclusive totals // total) with a `reasoning_tokens` subset. We pass the inclusive totals
// through and derive the non-cached breakdown so the `LLM.Usage` contract is // through and derive the non-cached breakdown so the `AI.Usage` contract is
// satisfied on both sides. // satisfied on both sides.
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => { const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
if (!usage) return undefined if (!usage) return undefined
@@ -546,7 +645,7 @@ const reasoningMetadata = (field: ParserState["reasoningField"], details?: Reado
const step = (state: ParserState, event: OpenAIChatEvent) => const step = (state: ParserState, event: OpenAIChatEvent) =>
Effect.gen(function* () { Effect.gen(function* () {
if (event.error) if (event.error)
return yield* new LLMError({ return yield* new AIError({
module: ADAPTER, module: ADAPTER,
method: "stream", method: "stream",
reason: classifyProviderFailure({ reason: classifyProviderFailure({
@@ -1,11 +1,11 @@
import { Route, type RouteRoutedModelInput } from "../route/client" import { Route, type RouteRoutedLanguageModelInput } from "../route/client"
import { Endpoint } from "../route/endpoint" import { Endpoint } from "../route/endpoint"
import { Framing } from "../route/framing" import { Framing } from "../route/framing"
import * as OpenAIChat from "./openai-chat" import * as OpenAIChat from "./openai-chat"
const ADAPTER = "openai-compatible-chat" const ADAPTER = "openai-compatible-chat"
export type OpenAICompatibleChatModelInput = RouteRoutedModelInput export type OpenAICompatibleChatLanguageModelInput = RouteRoutedLanguageModelInput
/** /**
* Route for non-OpenAI providers that expose an OpenAI Chat-compatible * Route for non-OpenAI providers that expose an OpenAI Chat-compatible
@@ -1,10 +1,10 @@
import { Route, type RouteRoutedModelInput } from "../route/client" import { Route, type RouteRoutedLanguageModelInput } from "../route/client"
import { Endpoint } from "../route/endpoint" import { Endpoint } from "../route/endpoint"
import { OpenResponses } from "./open-responses" import { OpenResponses } from "./open-responses"
const ADAPTER = "openai-compatible-responses" const ADAPTER = "openai-compatible-responses"
export type OpenAICompatibleResponsesModelInput = RouteRoutedModelInput export type OpenAICompatibleResponsesLanguageModelInput = RouteRoutedLanguageModelInput
/** /**
* Deployment adapter for providers that expose an Open Responses-compatible * Deployment adapter for providers that expose an Open Responses-compatible
+2 -2
View File
@@ -11,7 +11,7 @@ import {
import { Auth, type Definition as AuthDefinition } from "../route/auth" import { Auth, type Definition as AuthDefinition } from "../route/auth"
import { import {
InvalidProviderOutputReason, InvalidProviderOutputReason,
LLMError, AIError,
Usage, Usage,
mergeHttpOptions, mergeHttpOptions,
mergeJsonRecords, mergeJsonRecords,
@@ -85,7 +85,7 @@ const nativeOptions = (options: OpenAIImageOptions | undefined) => {
} }
const invalidOutput = (message: string) => const invalidOutput = (message: string) =>
new LLMError({ new AIError({
module: ADAPTER, module: ADAPTER,
method: "generate", method: "generate",
reason: new InvalidProviderOutputReason({ message, route: ADAPTER }), reason: new InvalidProviderOutputReason({ message, route: ADAPTER }),
+7 -7
View File
@@ -6,7 +6,7 @@ import { Headers, HttpClientRequest } from "effect/unstable/http"
import { import {
InvalidProviderOutputReason, InvalidProviderOutputReason,
InvalidRequestReason, InvalidRequestReason,
LLMError, AIError,
type ContentPart, type ContentPart,
type LLMRequest, type LLMRequest,
type MediaPart, type MediaPart,
@@ -41,7 +41,7 @@ export interface ToolAccumulator {
* when at least one is defined. Returns `undefined` when neither input nor * when at least one is defined. Returns `undefined` when neither input nor
* output is known so routes don't publish a misleading `0`. * output is known so routes don't publish a misleading `0`.
* *
* Under the additive `LLM.Usage` contract, `inputTokens` and `outputTokens` * Under the additive `AI.Usage` contract, `inputTokens` and `outputTokens`
* are the non-cached input and visible output only. The provider-supplied * are the non-cached input and visible output only. The provider-supplied
* `total` is the source of truth when present; the computed fallback * `total` is the source of truth when present; the computed fallback
* under-counts cache and reasoning by design and exists mainly so * under-counts cache and reasoning by design and exists mainly so
@@ -88,7 +88,7 @@ export const sumTokens = (...values: ReadonlyArray<number | undefined>): number
} }
export const eventError = (route: string, message: string, raw?: string) => export const eventError = (route: string, message: string, raw?: string) =>
new LLMError({ new AIError({
module: "ProviderShared", module: "ProviderShared",
method: "stream", method: "stream",
reason: new InvalidProviderOutputReason({ route, message, raw }), reason: new InvalidProviderOutputReason({ route, message, raw }),
@@ -238,9 +238,9 @@ export const errorText = (error: unknown) => {
* `decodeChunk` sees one JSON string per element. The SSE channel emits a * `decodeChunk` sees one JSON string per element. The SSE channel emits a
* `Retry` control event on its error channel; we drop it here (we don't * `Retry` control event on its error channel; we drop it here (we don't
* implement client-driven retries) so the public error channel stays * implement client-driven retries) so the public error channel stays
* `LLMError`. * `AIError`.
*/ */
export const sseFraming = (bytes: Stream.Stream<Uint8Array, LLMError>): Stream.Stream<string, LLMError> => export const sseFraming = (bytes: Stream.Stream<Uint8Array, AIError>): Stream.Stream<string, AIError> =>
bytes.pipe( bytes.pipe(
Stream.decodeText(), Stream.decodeText(),
Stream.pipeThroughChannel(Sse.decode()), Stream.pipeThroughChannel(Sse.decode()),
@@ -257,7 +257,7 @@ export const sseFraming = (bytes: Stream.Stream<Uint8Array, LLMError>): Stream.S
* lands here. * lands here.
*/ */
export const invalidRequest = (message: string) => export const invalidRequest = (message: string) =>
new LLMError({ new AIError({
module: "ProviderShared", module: "ProviderShared",
method: "request", method: "request",
reason: new InvalidRequestReason({ message }), reason: new InvalidRequestReason({ message }),
@@ -304,7 +304,7 @@ export const unsupportedContent = (
* Build a `validate` step from a Schema decoder. Replaces the per-route * Build a `validate` step from a Schema decoder. Replaces the per-route
* lambda body `(payload) => decode(payload).pipe(Effect.mapError((e) => * lambda body `(payload) => decode(payload).pipe(Effect.mapError((e) =>
* invalid(e.message)))`. Any decode error is translated into * invalid(e.message)))`. Any decode error is translated into
* `LLMError` carrying the original parse-error message. * `AIError` carrying the original parse-error message.
*/ */
export const validateWith = export const validateWith =
<A, I, E extends { readonly message: string }>(decode: (input: I) => Effect.Effect<A, E>) => <A, I, E extends { readonly message: string }>(decode: (input: I) => Effect.Effect<A, E>) =>
@@ -22,6 +22,8 @@ const signRequest = (input: {
readonly body: string readonly body: string
readonly headers: Headers.Headers readonly headers: Headers.Headers
readonly credentials: Credentials readonly credentials: Credentials
readonly service: string
readonly name: string
}) => }) =>
Effect.tryPromise({ Effect.tryPromise({
try: async () => { try: async () => {
@@ -34,23 +36,26 @@ const signRequest = (input: {
accessKeyId: input.credentials.accessKeyId, accessKeyId: input.credentials.accessKeyId,
secretAccessKey: input.credentials.secretAccessKey, secretAccessKey: input.credentials.secretAccessKey,
sessionToken: input.credentials.sessionToken, sessionToken: input.credentials.sessionToken,
service: "bedrock", service: input.service,
}).sign() }).sign()
return Object.fromEntries(signed.headers.entries()) return Object.fromEntries(signed.headers.entries())
}, },
catch: (error) => catch: (error) =>
ProviderShared.invalidRequest( ProviderShared.invalidRequest(
`Bedrock Converse SigV4 signing failed: ${error instanceof Error ? error.message : String(error)}`, `${input.name} SigV4 signing failed: ${error instanceof Error ? error.message : String(error)}`,
), ),
}) })
/** Sign the exact JSON bytes with SigV4 using credentials configured on the route. */ /** Sign the exact JSON bytes with SigV4 using credentials configured on the route. */
export const sigV4 = (credentials: Credentials | undefined) => export const sigV4 = (
credentials: Credentials | undefined,
options: { readonly service?: string; readonly name?: string } = {},
) =>
Auth.custom((input: AuthInput) => { Auth.custom((input: AuthInput) => {
return Effect.gen(function* () { return Effect.gen(function* () {
if (!credentials) { if (!credentials) {
return yield* ProviderShared.invalidRequest( return yield* ProviderShared.invalidRequest(
"Bedrock Converse requires either route bearer auth or AWS credentials configured on the route", `${options.name ?? "Bedrock Converse"} requires either route bearer auth or AWS credentials configured on the route`,
) )
} }
const headersForSigning = Headers.set(input.headers, "content-type", "application/json") const headersForSigning = Headers.set(input.headers, "content-type", "application/json")
@@ -59,6 +64,8 @@ export const sigV4 = (credentials: Credentials | undefined) =>
body: input.body, body: input.body,
headers: headersForSigning, headers: headersForSigning,
credentials, credentials,
service: options.service ?? "bedrock",
name: options.name ?? "Bedrock Converse",
}) })
return Headers.setAll(headersForSigning, signed) return Headers.setAll(headersForSigning, signed)
}) })
@@ -1,9 +1,9 @@
import { Effect, Encoding } from "effect" import { Effect, Encoding } from "effect"
import type { ImageInput } from "../../image" import type { ImageInput } from "../../image"
import { InvalidRequestReason, LLMError } from "../../schema" import { InvalidRequestReason, AIError } from "../../schema"
const invalid = (module: string, message: string) => const invalid = (module: string, message: string) =>
new LLMError({ new AIError({
module, module,
method: "generate", method: "generate",
reason: new InvalidRequestReason({ message }), reason: new InvalidRequestReason({ message }),
@@ -15,7 +15,7 @@ export const dataUrl = (input: Extract<ImageInput, { readonly type: "bytes" }>)
export const decodeDataUrl = ( export const decodeDataUrl = (
url: string, url: string,
module: string, module: string,
): Effect.Effect<{ readonly mediaType: string; readonly data: Uint8Array } | undefined, LLMError> => { ): Effect.Effect<{ readonly mediaType: string; readonly data: Uint8Array } | undefined, AIError> => {
if (!url.startsWith("data:")) return Effect.succeed(undefined) if (!url.startsWith("data:")) return Effect.succeed(undefined)
const match = /^data:([^;,]+);base64,(.*)$/s.exec(url) const match = /^data:([^;,]+);base64,(.*)$/s.exec(url)
if (!match) return Effect.fail(invalid(module, "Image data URLs must contain a MIME type and base64 data")) if (!match) return Effect.fail(invalid(module, "Image data URLs must contain a MIME type and base64 data"))
@@ -1,4 +1,4 @@
import type { JsonSchema, ModelToolSchemaCompatibility } from "../../schema" import type { JsonSchema, LanguageModelToolSchemaCompatibility } from "../../schema"
import { isRecord } from "../../utils/record" import { isRecord } from "../../utils/record"
import { GeminiToolSchema } from "./gemini-tool-schema" import { GeminiToolSchema } from "./gemini-tool-schema"
@@ -69,7 +69,7 @@ const gemini = (schema: JsonSchema): JsonSchema => GeminiToolSchema.convert(sche
const modelCompatibility = ( const modelCompatibility = (
schema: JsonSchema, schema: JsonSchema,
compatibility: ModelToolSchemaCompatibility | undefined, compatibility: LanguageModelToolSchemaCompatibility | undefined,
): JsonSchema => { ): JsonSchema => {
if (compatibility === undefined) return schema if (compatibility === undefined) return schema
switch (compatibility) { switch (compatibility) {
@@ -1,5 +1,5 @@
import { Effect } from "effect" import { Effect } from "effect"
import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputError } from "../../schema" import { AIError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputError } from "../../schema"
import { eventError, parseToolInput, type ToolAccumulator } from "../shared" import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
type StreamKey = string | number type StreamKey = string | number
@@ -112,8 +112,8 @@ const appendTool = <K extends StreamKey>(
} }
} }
export const isError = <K extends StreamKey>(result: AppendOutcome<K> | LLMError): result is LLMError => export const isError = <K extends StreamKey>(result: AppendOutcome<K> | AIError): result is AIError =>
result instanceof LLMError result instanceof AIError
/** /**
* Register a tool call whose start event arrived before any argument deltas. * Register a tool call whose start event arrived before any argument deltas.
@@ -138,7 +138,7 @@ export const appendOrStart = <K extends StreamKey>(
key: K, key: K,
delta: { readonly id?: string; readonly name?: string; readonly text: string }, delta: { readonly id?: string; readonly name?: string; readonly text: string },
missingToolMessage: string, missingToolMessage: string,
): AppendOutcome<K> | LLMError => { ): AppendOutcome<K> | AIError => {
const current = tools[key] const current = tools[key]
const id = current?.id ?? delta.id const id = current?.id ?? delta.id
const name = current?.name ?? delta.name const name = current?.name ?? delta.name
@@ -167,7 +167,7 @@ export const appendExisting = <K extends StreamKey>(
key: K, key: K,
text: string, text: string,
missingToolMessage: string, missingToolMessage: string,
): AppendOutcome<K> | LLMError => { ): AppendOutcome<K> | AIError => {
const current = tools[key] const current = tools[key]
if (!current) return eventError(route, missingToolMessage) if (!current) return eventError(route, missingToolMessage)
if (text.length === 0) return { tools, tool: current, events: [] } if (text.length === 0) return { tools, tool: current, events: [] }
+2 -2
View File
@@ -4,7 +4,7 @@ import { GeneratedImage, ImageModel, ImageResponse, type ImageRequestFor, type I
import { Auth, type Definition as AuthDefinition } from "../route/auth" import { Auth, type Definition as AuthDefinition } from "../route/auth"
import { import {
InvalidProviderOutputReason, InvalidProviderOutputReason,
LLMError, AIError,
Usage, Usage,
mergeHttpOptions, mergeHttpOptions,
mergeJsonRecords, mergeJsonRecords,
@@ -95,7 +95,7 @@ const nativeOptions = (options: XAIImageOptions | undefined) => {
} }
const invalidOutput = (message: string) => const invalidOutput = (message: string) =>
new LLMError({ new AIError({
module: ADAPTER, module: ADAPTER,
method: "generate", method: "generate",
reason: new InvalidProviderOutputReason({ message, route: ADAPTER }), reason: new InvalidProviderOutputReason({ message, route: ADAPTER }),
+2 -2
View File
@@ -2,7 +2,7 @@ import { Effect, Schema } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http" import { Headers, HttpClientRequest } from "effect/unstable/http"
import { GeneratedImage, ImageModel, ImageResponse, type ImageRequestFor, type ImageRoute } from "../image" import { GeneratedImage, ImageModel, ImageResponse, type ImageRequestFor, type ImageRoute } from "../image"
import { Auth, type Definition as AuthDefinition } from "../route/auth" import { Auth, type Definition as AuthDefinition } from "../route/auth"
import { InvalidProviderOutputReason, LLMError, mergeHttpOptions, mergeJsonRecords, type HttpOptions } from "../schema" import { InvalidProviderOutputReason, AIError, mergeHttpOptions, mergeJsonRecords, type HttpOptions } from "../schema"
import { ProviderShared } from "./shared" import { ProviderShared } from "./shared"
import { ImageInputs } from "./utils/image-input" import { ImageInputs } from "./utils/image-input"
@@ -58,7 +58,7 @@ const nativeOptions = (options: ZAIImageOptions | undefined) => {
} }
const invalidOutput = (message: string) => const invalidOutput = (message: string) =>
new LLMError({ new AIError({
module: ADAPTER, module: ADAPTER,
method: "generate", method: "generate",
reason: new InvalidProviderOutputReason({ message, route: ADAPTER }), reason: new InvalidProviderOutputReason({ message, route: ADAPTER }),
+11 -12
View File
@@ -3,7 +3,7 @@ import {
AuthenticationReason, AuthenticationReason,
ContentPolicyReason, ContentPolicyReason,
InvalidRequestReason, InvalidRequestReason,
LLMError, AIError,
ProviderErrorEvent, ProviderErrorEvent,
ProviderInternalReason, ProviderInternalReason,
QuotaExceededReason, QuotaExceededReason,
@@ -16,7 +16,6 @@ import {
const patterns = [ const patterns = [
/prompt is too long/i, /prompt is too long/i,
/request_too_large/i,
/input is too long for requested model/i, /input is too long for requested model/i,
/exceeds the context window/i, /exceeds the context window/i,
/exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i, /exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i,
@@ -33,7 +32,6 @@ const patterns = [
/context window exceeds limit/i, /context window exceeds limit/i,
/exceeded model token limit/i, /exceeded model token limit/i,
/context[_ ]length[_ ]exceeded/i, /context[_ ]length[_ ]exceeded/i,
/request entity too large/i,
/context length is only \d+ tokens/i, /context length is only \d+ tokens/i,
/input length.*exceeds.*context length/i, /input length.*exceeds.*context length/i,
/prompt too long; exceeded (?:max )?context length/i, /prompt too long; exceeded (?:max )?context length/i,
@@ -44,14 +42,18 @@ const patterns = [
/token limit exceeded/i, /token limit exceeded/i,
] ]
const payloadPatterns = [/request_too_large/i, /request entity too large/i, /payload too large/i, /request too large/i]
const exclusions = [/^(throttling error|service unavailable):/i, /rate limit/i, /too many requests/i] const exclusions = [/^(throttling error|service unavailable):/i, /rate limit/i, /too many requests/i]
export const isContextOverflow = (message: string) => export const isContextOverflow = (message: string) =>
!exclusions.some((pattern) => pattern.test(message)) && !exclusions.some((pattern) => pattern.test(message)) &&
(patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message)) (patterns.some((pattern) => pattern.test(message)) || /^400\s*(status code)?\s*\(no body\)/i.test(message))
export const isPayloadTooLarge = (message: string) => payloadPatterns.some((pattern) => pattern.test(message))
export const isContextOverflowFailure = (failure: unknown) => export const isContextOverflowFailure = (failure: unknown) =>
failure instanceof LLMError failure instanceof AIError
? failure.reason._tag === "InvalidRequest" && failure.reason.classification === "context-overflow" ? failure.reason._tag === "InvalidRequest" && failure.reason.classification === "context-overflow"
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow" : Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
@@ -84,7 +86,7 @@ export interface ProviderFailure {
// Keep HTTP failures and provider-reported stream failures on one typed path so // Keep HTTP failures and provider-reported stream failures on one typed path so
// session retry policy never needs provider-specific string matching. // session retry policy never needs provider-specific string matching.
export function classifyProviderFailure(input: ProviderFailure): LLMError["reason"] { export function classifyProviderFailure(input: ProviderFailure): AIError["reason"] {
const body = input.http?.body ?? "" const body = input.http?.body ?? ""
const codes = [input.code, ...providerCodes(body), ...providerCodes(input.message)] const codes = [input.code, ...providerCodes(body), ...providerCodes(input.message)]
.filter((code): code is string => code !== undefined) .filter((code): code is string => code !== undefined)
@@ -100,6 +102,8 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
isContextOverflow(text)) isContextOverflow(text))
) )
return new InvalidRequestReason({ ...common, classification: "context-overflow" }) return new InvalidRequestReason({ ...common, classification: "context-overflow" })
if (input.status === 413 || isPayloadTooLarge(text))
return new InvalidRequestReason({ ...common, classification: "payload-too-large" })
if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyReason(common) if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyReason(common)
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text))) if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
return new QuotaExceededReason(common) return new QuotaExceededReason(common)
@@ -142,12 +146,7 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
retryAfterMs: input.retryAfterMs, retryAfterMs: input.retryAfterMs,
}) })
if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestReason(common) if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestReason(common)
if ( if (input.status === 400 || input.status === 404 || input.status === 413 || input.status === 422)
input.status === 400 ||
input.status === 404 ||
input.status === 413 ||
input.status === 422
)
return new InvalidRequestReason(common) return new InvalidRequestReason(common)
return new UnknownProviderReason({ ...common, status: input.status }) return new UnknownProviderReason({ ...common, status: input.status })
} }
+8 -3
View File
@@ -1,16 +1,21 @@
import type { Model } from "./schema" import type { LanguageModel, ProviderOptions } from "./schema"
export interface Settings extends Readonly<Record<string, unknown>> { export interface Settings extends Readonly<Record<string, unknown>> {
readonly baseURL?: string
readonly headers?: Readonly<Record<string, string>> readonly headers?: Readonly<Record<string, string>>
readonly body?: Readonly<Record<string, unknown>> readonly body?: Readonly<Record<string, unknown>>
readonly limits?: { readonly limits?: {
readonly context: number readonly context: number
readonly input?: number
readonly output: number readonly output: number
} }
} }
export interface Definition<ProviderSettings extends Settings = Settings> { export interface Definition<
readonly model: (modelID: string, settings: ProviderSettings) => Model ProviderSettings extends Settings = Settings,
Options extends ProviderOptions = ProviderOptions,
> {
readonly model: (modelID: string, settings: ProviderSettings) => LanguageModel<Options>
} }
export * as ProviderPackage from "./provider-package" export * as ProviderPackage from "./provider-package"
+9 -9
View File
@@ -1,6 +1,6 @@
import type { Model, ModelID, ProviderID } from "./schema" import type { LanguageModel, ModelID, ProviderID } from "./schema"
export type ModelOptions = Pick<Model.Input, "defaults" | "compatibility"> export type LanguageModelOptions = Pick<LanguageModel.Input, "defaults" | "compatibility">
/** /**
* Advanced structural provider definition helper. Built-in providers should * Advanced structural provider definition helper. Built-in providers should
@@ -8,23 +8,23 @@ export type ModelOptions = Pick<Model.Input, "defaults" | "compatibility">
* chosen before model selection. The optional `apis` map remains for external * chosen before model selection. The optional `apis` map remains for external
* structural providers that expose multiple route selectors behind one provider. * structural providers that expose multiple route selectors behind one provider.
*/ */
export type ModelFactory<Options extends ModelOptions = ModelOptions> = ( export type LanguageModelFactory<Options extends LanguageModelOptions = LanguageModelOptions> = (
id: string | ModelID, id: string | ModelID,
options?: Options, options?: Options,
) => Model ) => LanguageModel
type AnyModelFactory = (...args: never[]) => Model type AnyLanguageModelFactory = (...args: never[]) => LanguageModel
export interface Definition<Factory extends AnyModelFactory = ModelFactory> { export interface Definition<Factory extends AnyLanguageModelFactory = LanguageModelFactory> {
readonly id: ProviderID readonly id: ProviderID
readonly model: Factory readonly model: Factory
readonly apis?: Record<string, AnyModelFactory> readonly apis?: Record<string, AnyLanguageModelFactory>
} }
type DefinitionShape = { type DefinitionShape = {
readonly id: ProviderID readonly id: ProviderID
readonly model: (...args: never[]) => Model readonly model: (...args: never[]) => LanguageModel
readonly apis?: Record<string, (...args: never[]) => Model> readonly apis?: Record<string, (...args: never[]) => LanguageModel>
} }
type NoExtraFields<Input, Shape> = Input & Record<Exclude<keyof Input, keyof Shape>, never> type NoExtraFields<Input, Shape> = Input & Record<Exclude<keyof Input, keyof Shape>, never>
@@ -0,0 +1,107 @@
import { Auth } from "../route/auth"
import type { Route as RouteDef, RouteDefaultsInput } from "../route/client"
import type { ProviderPackage } from "../provider-package"
import { OpenAIChat } from "../protocols/openai-chat"
import { OpenAIResponses } from "../protocols/openai-responses"
import { BedrockAuth, type Credentials } from "../protocols/utils/bedrock-auth"
import { ProviderID, type ModelID } from "../schema"
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
export const id = ProviderID.make("amazon-bedrock")
export type Config = RouteDefaultsInput & {
readonly apiKey?: string
readonly baseURL?: string
readonly credentials?: Credentials
readonly region?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly auth?: "bearer" | "sigv4"
readonly baseURL?: string
readonly credentials?: Credentials
readonly region?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
const responsesRoute = OpenAIResponses.route.with({
id: "bedrock-mantle-responses",
provider: id,
})
const chatRoute = OpenAIChat.route.with({
id: "bedrock-mantle-chat",
provider: id,
})
export const routes = [responsesRoute, chatRoute]
const configuredRoute = <Body, Prepared>(route: RouteDef<Body, Prepared>, input: Config) => {
const region = input.region ?? input.credentials?.region ?? "us-east-1"
const credentials = input.credentials === undefined ? undefined : { ...input.credentials, region }
return route.with({
endpoint: { baseURL: input.baseURL ?? `https://bedrock-mantle.${region}.api.aws/v1` },
auth:
input.apiKey === undefined
? BedrockAuth.sigV4(credentials, { service: "bedrock-mantle", name: "Bedrock Mantle" })
: Auth.bearer(input.apiKey),
})
}
const defaults = (input: Config) => {
const { apiKey: _, baseURL: _baseURL, credentials: _credentials, region: _region, ...rest } = input
return rest
}
export const configure = (input: Config = {}) => {
const configuredResponsesRoute = configuredRoute(responsesRoute, input)
const configuredChatRoute = configuredRoute(chatRoute, input)
const modelDefaults = defaults(input)
const responses = (modelID: string | ModelID) =>
configuredResponsesRoute
.with(withOpenAIOptions(modelID, modelDefaults))
.model<OpenAIProviderOptionsInput>({ id: modelID })
const chat = (modelID: string | ModelID) =>
configuredChatRoute
.with(withOpenAIOptions(modelID, modelDefaults))
.model<OpenAIProviderOptionsInput>({ id: modelID })
return {
id,
model: chat,
chat,
responses,
configure,
}
}
export const provider = configure()
const config = (settings: Settings): Config => {
if (settings.auth === "bearer" && settings.apiKey === undefined)
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
if (settings.auth === "sigv4" && settings.apiKey !== undefined)
throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey")
return {
apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey,
baseURL: settings.baseURL,
credentials: settings.credentials,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
region: settings.region,
}
}
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => configure(config(settings)).chat(modelID)
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => configure(config(settings)).responses(modelID)
export const model = chatModel
@@ -0,0 +1,2 @@
export { chatModel as model } from "../amazon-bedrock-mantle"
export type { Settings } from "../amazon-bedrock-mantle"
@@ -0,0 +1,2 @@
export { chatModel as model } from "../../amazon-bedrock-mantle"
export type { Settings } from "../../amazon-bedrock-mantle"
@@ -0,0 +1,2 @@
export { responsesModel as model } from "../../amazon-bedrock-mantle"
export type { Settings } from "../../amazon-bedrock-mantle"
@@ -47,7 +47,7 @@ export const configure = (input: Config) => {
}) })
return { return {
id: ProviderID.make(provider), id: ProviderID.make(provider),
model: (modelID: string | ModelID) => route.model({ id: modelID }), model: (modelID: string | ModelID) => route.model<AnthropicMessages.ProviderOptionsInput>({ id: modelID }),
configure, configure,
} }
} }
@@ -57,7 +57,10 @@ export const provider = {
configure, configure,
} }
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => { export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
modelID,
settings,
) => {
if (settings.apiKey !== undefined && settings.authToken !== undefined) if (settings.apiKey !== undefined && settings.authToken !== undefined)
throw new Error("Anthropic-compatible apiKey cannot be combined with authToken") throw new Error("Anthropic-compatible apiKey cannot be combined with authToken")
return configure({ return configure({
+4 -1
View File
@@ -52,7 +52,10 @@ export const configure = (input: Config = {}) => {
} }
export const provider = configure() export const provider = configure()
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => { export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
modelID,
settings,
) => {
if (settings.apiKey !== undefined && settings.authToken !== undefined) if (settings.apiKey !== undefined && settings.authToken !== undefined)
throw new Error("Anthropic apiKey cannot be combined with authToken") throw new Error("Anthropic apiKey cannot be combined with authToken")
return configure({ return configure({
+16 -8
View File
@@ -14,7 +14,7 @@ const routeAuth = Auth.remove("authorization")
// (helper builds the URL) or `baseURL` directly. // (helper builds the URL) or `baseURL` directly.
type AzureURL = AtLeastOne<{ readonly resourceName: string; readonly baseURL: string }> type AzureURL = AtLeastOne<{ readonly resourceName: string; readonly baseURL: string }>
export type ModelOptions = AzureURL & export type LanguageModelOptions = AzureURL &
RouteDefaultsInput & RouteDefaultsInput &
ProviderAuthOption<"optional"> & { ProviderAuthOption<"optional"> & {
readonly apiVersion?: string readonly apiVersion?: string
@@ -22,7 +22,7 @@ export type ModelOptions = AzureURL &
readonly useCompletionUrls?: boolean readonly useCompletionUrls?: boolean
readonly providerOptions?: OpenAIProviderOptionsInput readonly providerOptions?: OpenAIProviderOptionsInput
} }
export type Config = ModelOptions export type Config = LanguageModelOptions
export type Settings = ProviderPackage.Settings & export type Settings = ProviderPackage.Settings &
AzureURL & { AzureURL & {
@@ -99,10 +99,14 @@ export const configure = (input: Config) => {
const modelDefaults = defaults(input) const modelDefaults = defaults(input)
const responses = (modelID: string | ModelID) => const responses = (modelID: string | ModelID) =>
configuredResponsesRoute.with(withOpenAIOptions(modelID, modelDefaults)).model({ id: modelID }) configuredResponsesRoute
.with(withOpenAIOptions(modelID, modelDefaults))
.model<OpenAIProviderOptionsInput>({ id: modelID })
const chat = (modelID: string | ModelID) => const chat = (modelID: string | ModelID) =>
configuredChatRoute.with(withOpenAIOptions(modelID, modelDefaults)).model({ id: modelID }) configuredChatRoute
.with(withOpenAIOptions(modelID, modelDefaults))
.model<OpenAIProviderOptionsInput>({ id: modelID })
return { return {
id, id,
@@ -133,8 +137,12 @@ const config = (settings: Settings): Config => {
throw new Error("Azure requires resourceName or baseURL") throw new Error("Azure requires resourceName or baseURL")
} }
export const responsesModel: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
configure(config(settings)).responses(modelID) modelID,
export const chatModel: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => settings,
configure(config(settings)).chat(modelID) ) => configure(config(settings)).responses(modelID)
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => configure(config(settings)).chat(modelID)
export const model = responsesModel export const model = responsesModel
+10 -4
View File
@@ -4,6 +4,7 @@ import { Auth } from "../route/auth"
import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/auth-options" import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/auth-options"
import type { RouteDefaultsInput } from "../route/client" import type { RouteDefaultsInput } from "../route/client"
import { ProviderID, type ModelID } from "../schema" import { ProviderID, type ModelID } from "../schema"
import type { OpenAIProviderOptionsInput } from "./openai-options"
export const aiGatewayID = ProviderID.make("cloudflare-ai-gateway") export const aiGatewayID = ProviderID.make("cloudflare-ai-gateway")
export const workersAIID = ProviderID.make("cloudflare-workers-ai") export const workersAIID = ProviderID.make("cloudflare-workers-ai")
@@ -20,10 +21,11 @@ type GatewayURL = AtLeastOne<{
} }
export type AIGatewayOptions = GatewayURL & export type AIGatewayOptions = GatewayURL &
RouteDefaultsInput & Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & { ProviderAuthOption<"optional"> & {
/** Cloudflare AI Gateway authentication token. Sent as `cf-aig-authorization`. */ /** Cloudflare AI Gateway authentication token. Sent as `cf-aig-authorization`. */
readonly gatewayApiKey?: CloudflareSecret readonly gatewayApiKey?: CloudflareSecret
readonly providerOptions?: OpenAIProviderOptionsInput
} }
type WorkersAIURL = AtLeastOne<{ type WorkersAIURL = AtLeastOne<{
@@ -31,7 +33,11 @@ type WorkersAIURL = AtLeastOne<{
readonly baseURL: string readonly baseURL: string
}> }>
export type WorkersAIOptions = WorkersAIURL & RouteDefaultsInput & ProviderAuthOption<"optional"> export type WorkersAIOptions = WorkersAIURL &
Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const aiGatewayBaseURL = (input: GatewayURL) => { export const aiGatewayBaseURL = (input: GatewayURL) => {
if (input.baseURL) return input.baseURL if (input.baseURL) return input.baseURL
@@ -98,7 +104,7 @@ const configureAIGateway = (options: AIGatewayOptions) => {
}) })
return { return {
id: aiGatewayID, id: aiGatewayID,
model: (modelID: string | ModelID) => route.model({ id: modelID }), model: (modelID: string | ModelID) => route.model<OpenAIProviderOptionsInput>({ id: modelID }),
configure: configureAIGateway, configure: configureAIGateway,
} }
} }
@@ -111,7 +117,7 @@ const configureWorkersAI = (options: WorkersAIOptions) => {
}) })
return { return {
id: workersAIID, id: workersAIID,
model: (modelID: string | ModelID) => route.model({ id: modelID }), model: (modelID: string | ModelID) => route.model<OpenAIProviderOptionsInput>({ id: modelID }),
configure: configureWorkersAI, configure: configureWorkersAI,
} }
} }
+10 -8
View File
@@ -9,14 +9,14 @@ export const id = ProviderID.make("github-copilot")
// GitHub Copilot has no canonical public URL — callers (opencode, etc.) must // GitHub Copilot has no canonical public URL — callers (opencode, etc.) must
// supply `baseURL` explicitly. // supply `baseURL` explicitly.
export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> & export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & { ProviderAuthOption<"optional"> & {
readonly baseURL: string readonly baseURL: string
readonly endpoint?: "chat" | "responses" readonly endpoint?: "chat" | "responses"
readonly providerOptions?: OpenAIProviderOptionsInput readonly providerOptions?: OpenAIProviderOptionsInput
} }
export const shouldUseResponsesApi = (modelID: string | ModelID, endpoint?: ModelOptions["endpoint"]) => { export const shouldUseResponsesApi = (modelID: string | ModelID, endpoint?: LanguageModelOptions["endpoint"]) => {
if (endpoint) return endpoint === "responses" if (endpoint) return endpoint === "responses"
const model = String(modelID) const model = String(modelID)
const match = /^gpt-(\d+)/.exec(model) const match = /^gpt-(\d+)/.exec(model)
@@ -29,30 +29,32 @@ export const routes = [OpenAIResponses.route, OpenAIChat.route]
const chatRoute = OpenAIChat.route.with({ provider: id }) const chatRoute = OpenAIChat.route.with({ provider: id })
const responsesRoute = OpenAIResponses.route.with({ provider: id }) const responsesRoute = OpenAIResponses.route.with({ provider: id })
const defaults = (options: ModelOptions) => { const defaults = (options: LanguageModelOptions) => {
const { apiKey: _, auth: _auth, baseURL: _baseURL, endpoint: _endpoint, ...rest } = options const { apiKey: _, auth: _auth, baseURL: _baseURL, endpoint: _endpoint, ...rest } = options
return rest return rest
} }
const configuredResponsesRoute = (options: ModelOptions) => const configuredResponsesRoute = (options: LanguageModelOptions) =>
responsesRoute.with({ responsesRoute.with({
endpoint: { baseURL: options.baseURL }, endpoint: { baseURL: options.baseURL },
auth: AuthOptions.bearer(options, []), auth: AuthOptions.bearer(options, []),
}) })
const configuredChatRoute = (options: ModelOptions) => const configuredChatRoute = (options: LanguageModelOptions) =>
chatRoute.with({ chatRoute.with({
endpoint: { baseURL: options.baseURL }, endpoint: { baseURL: options.baseURL },
auth: AuthOptions.bearer(options, []), auth: AuthOptions.bearer(options, []),
}) })
export const configure = (options: ModelOptions) => { export const configure = (options: LanguageModelOptions) => {
const responsesRoute = configuredResponsesRoute(options) const responsesRoute = configuredResponsesRoute(options)
const chatRoute = configuredChatRoute(options) const chatRoute = configuredChatRoute(options)
const responses = (modelID: string | ModelID) => const responses = (modelID: string | ModelID) =>
responsesRoute.with(withOpenAIOptions(modelID, defaults(options))).model({ id: modelID }) responsesRoute
.with(withOpenAIOptions(modelID, defaults(options)))
.model<OpenAIProviderOptionsInput>({ id: modelID })
const chat = (modelID: string | ModelID) => const chat = (modelID: string | ModelID) =>
chatRoute.with(withOpenAIOptions(modelID, defaults(options))).model({ id: modelID }) chatRoute.with(withOpenAIOptions(modelID, defaults(options))).model<OpenAIProviderOptionsInput>({ id: modelID })
return { return {
id, id,
model: (modelID: string | ModelID) => model: (modelID: string | ModelID) =>
@@ -1,8 +1,9 @@
import type { ProviderPackage } from "../provider-package" import type { ProviderPackage } from "../provider-package"
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat" import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat"
import type { RouteDefaultsInput } from "../route/client" import type { RouteDefaultsInput } from "../route/client"
import { ProviderID, type ModelID, type ProviderOptions } from "../schema" import { ProviderID, type ModelID } from "../schema"
import { GoogleVertexShared } from "./google-vertex-shared" import { GoogleVertexShared } from "./google-vertex-shared"
import type { OpenAIProviderOptionsInput } from "./openai-options"
export const id = ProviderID.make("google-vertex") export const id = ProviderID.make("google-vertex")
@@ -11,6 +12,7 @@ export type Config = RouteDefaultsInput &
readonly baseURL?: string readonly baseURL?: string
readonly location?: string readonly location?: string
readonly project?: string readonly project?: string
readonly providerOptions?: OpenAIProviderOptionsInput
} }
export interface Settings extends ProviderPackage.Settings { export interface Settings extends ProviderPackage.Settings {
@@ -19,7 +21,7 @@ export interface Settings extends ProviderPackage.Settings {
readonly baseURL?: string readonly baseURL?: string
readonly location?: string readonly location?: string
readonly project?: string readonly project?: string
readonly providerOptions?: ProviderOptions readonly providerOptions?: OpenAIProviderOptionsInput
} }
const route = OpenAICompatibleChat.route.with({ const route = OpenAICompatibleChat.route.with({
@@ -56,7 +58,7 @@ export const configure = (input: Config = {}) => {
const route = configuredRoute(input) const route = configuredRoute(input)
return { return {
id, id,
model: (modelID: string | ModelID) => route.model({ id: modelID }), model: (modelID: string | ModelID) => route.model<OpenAIProviderOptionsInput>({ id: modelID }),
configure, configure,
} }
} }
@@ -66,7 +68,7 @@ export const provider = {
configure, configure,
} }
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => { export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
if (settings.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys") if (settings.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
return configure({ return configure({
accessToken: settings.accessToken, accessToken: settings.accessToken,
@@ -91,7 +91,7 @@ export const configure = (input: Config = {}) => {
const route = configuredRoute(input) const route = configuredRoute(input)
return { return {
id, id,
model: (modelID: string | ModelID) => route.model({ id: modelID }), model: (modelID: string | ModelID) => route.model<AnthropicMessages.ProviderOptionsInput>({ id: modelID }),
configure, configure,
} }
} }
@@ -101,7 +101,10 @@ export const provider = {
configure, configure,
} }
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => { export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
modelID,
settings,
) => {
if (settings.apiKey !== undefined) throw new Error("Google Vertex Messages does not support API keys") if (settings.apiKey !== undefined) throw new Error("Google Vertex Messages does not support API keys")
return configure({ return configure({
accessToken: settings.accessToken, accessToken: settings.accessToken,
@@ -1,8 +1,9 @@
import type { ProviderPackage } from "../provider-package" import type { ProviderPackage } from "../provider-package"
import { OpenAICompatibleResponses } from "../protocols/openai-compatible-responses" import { OpenAICompatibleResponses } from "../protocols/openai-compatible-responses"
import type { RouteDefaultsInput } from "../route/client" import type { RouteDefaultsInput } from "../route/client"
import { ProviderID, type ModelID, type ProviderOptions } from "../schema" import { ProviderID, type ModelID } from "../schema"
import { GoogleVertexShared } from "./google-vertex-shared" import { GoogleVertexShared } from "./google-vertex-shared"
import type { OpenResponsesProviderOptionsInput } from "./open-responses-options"
export const id = ProviderID.make("google-vertex") export const id = ProviderID.make("google-vertex")
@@ -11,6 +12,7 @@ export type Config = RouteDefaultsInput &
readonly baseURL?: string readonly baseURL?: string
readonly location?: string readonly location?: string
readonly project?: string readonly project?: string
readonly providerOptions?: OpenResponsesProviderOptionsInput
} }
export interface Settings extends ProviderPackage.Settings { export interface Settings extends ProviderPackage.Settings {
@@ -19,7 +21,7 @@ export interface Settings extends ProviderPackage.Settings {
readonly baseURL?: string readonly baseURL?: string
readonly location?: string readonly location?: string
readonly project?: string readonly project?: string
readonly providerOptions?: ProviderOptions readonly providerOptions?: OpenResponsesProviderOptionsInput
} }
const route = OpenAICompatibleResponses.route.with({ const route = OpenAICompatibleResponses.route.with({
@@ -58,7 +60,7 @@ export const configure = (input: Config = {}) => {
const route = configuredRoute(input) const route = configuredRoute(input)
return { return {
id, id,
model: (modelID: string | ModelID) => route.model({ id: modelID }), model: (modelID: string | ModelID) => route.model<OpenResponsesProviderOptionsInput>({ id: modelID }),
configure, configure,
} }
} }
@@ -68,7 +70,10 @@ export const provider = {
configure, configure,
} }
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => { export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (
modelID,
settings,
) => {
if (settings.apiKey !== undefined) throw new Error("Google Vertex Responses does not support API keys") if (settings.apiKey !== undefined) throw new Error("Google Vertex Responses does not support API keys")
return configure({ return configure({
accessToken: settings.accessToken, accessToken: settings.accessToken,
+6 -2
View File
@@ -77,7 +77,8 @@ const configuredRoute = (input: Config, modelID: string | ModelID) => {
export const configure = (input: Config = {}) => { export const configure = (input: Config = {}) => {
return { return {
id, id,
model: (modelID: string | ModelID) => configuredRoute(input, modelID).model({ id: modelID }), model: (modelID: string | ModelID) =>
configuredRoute(input, modelID).model<Gemini.ProviderOptionsInput>({ id: modelID }),
configure, configure,
} }
} }
@@ -86,7 +87,10 @@ export const provider = {
id, id,
configure, configure,
} }
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => { export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsInput>["model"] = (
modelID,
settings,
) => {
if (settings.apiKey !== undefined && settings.accessToken !== undefined) if (settings.apiKey !== undefined && settings.accessToken !== undefined)
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth") throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
return configure({ return configure({
+2 -2
View File
@@ -50,14 +50,14 @@ export const configure = (input: Config = {}) => {
}) })
return { return {
id, id,
model: (modelID: string | ModelID) => route.model({ id: modelID }), model: (modelID: string | ModelID) => route.model<Gemini.ProviderOptionsInput>({ id: modelID }),
image, image,
configure, configure,
} }
} }
export const provider = configure() export const provider = configure()
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsInput>["model"] = (modelID, settings) =>
configure({ configure({
apiKey: settings.apiKey, apiKey: settings.apiKey,
baseURL: settings.baseURL, baseURL: settings.baseURL,
+1
View File
@@ -1,6 +1,7 @@
export * as Anthropic from "./anthropic" export * as Anthropic from "./anthropic"
export * as AnthropicCompatible from "./anthropic-compatible" export * as AnthropicCompatible from "./anthropic-compatible"
export * as AmazonBedrock from "./amazon-bedrock" export * as AmazonBedrock from "./amazon-bedrock"
export * as AmazonBedrockMantle from "./amazon-bedrock-mantle"
export * as Azure from "./azure" export * as Azure from "./azure"
export * as Cloudflare from "./cloudflare" export * as Cloudflare from "./cloudflare"
export { CloudflareAIGateway, CloudflareWorkersAI } from "./cloudflare" export { CloudflareAIGateway, CloudflareWorkersAI } from "./cloudflare"
@@ -36,7 +36,7 @@ export const configure = (input: Config) => {
}) })
return { return {
id: ProviderID.make(provider), id: ProviderID.make(provider),
model: (modelID: string | ModelID) => route.model({ id: modelID }), model: (modelID: string | ModelID) => route.model<OpenResponsesProviderOptionsInput>({ id: modelID }),
configure, configure,
} }
} }
@@ -46,7 +46,10 @@ export const provider = {
configure, configure,
} }
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (
modelID,
settings,
) =>
configure({ configure({
apiKey: settings.apiKey, apiKey: settings.apiKey,
baseURL: settings.baseURL, baseURL: settings.baseURL,
@@ -4,13 +4,15 @@ import type { RouteDefaultsInput } from "../route/client"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import type { ProviderPackage } from "../provider-package" import type { ProviderPackage } from "../provider-package"
import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile" import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile"
import type { OpenAIProviderOptionsInput } from "./openai-options"
export const id = ProviderID.make("openai-compatible") export const id = ProviderID.make("openai-compatible")
type GenericModelOptions = RouteDefaultsInput & type GenericModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & { ProviderAuthOption<"optional"> & {
readonly provider?: string readonly provider?: string
readonly baseURL: string readonly baseURL: string
readonly providerOptions?: OpenAIProviderOptionsInput
} }
export interface Settings extends ProviderPackage.Settings { export interface Settings extends ProviderPackage.Settings {
@@ -19,9 +21,10 @@ export interface Settings extends ProviderPackage.Settings {
readonly provider?: string readonly provider?: string
} }
export type FamilyModelOptions = RouteDefaultsInput & export type FamilyModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & { ProviderAuthOption<"optional"> & {
readonly baseURL?: string readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
} }
export const routes = [OpenAICompatibleChat.route] export const routes = [OpenAICompatibleChat.route]
@@ -37,7 +40,8 @@ export const configure = (input: GenericModelOptions) => {
}) })
return { return {
id: ProviderID.make(provider), id: ProviderID.make(provider),
model: (modelID: string | ModelID) => route.model({ id: modelID, provider: ProviderID.make(provider) }), model: (modelID: string | ModelID) =>
route.model<OpenAIProviderOptionsInput>({ id: modelID, provider: ProviderID.make(provider) }),
configure, configure,
} }
} }
@@ -63,7 +67,7 @@ export const provider = {
configure, configure,
} }
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
configure({ configure({
apiKey: settings.apiKey, apiKey: settings.apiKey,
baseURL: settings.baseURL, baseURL: settings.baseURL,
+13 -6
View File
@@ -86,10 +86,15 @@ export const configure = (input: Config = {}) => {
const chatRoute = configuredRoute(OpenAIChat.route, input) const chatRoute = configuredRoute(OpenAIChat.route, input)
const modelDefaults = defaults(input) const modelDefaults = defaults(input)
const responses = (id: string | ModelID) => const responses = (id: string | ModelID) =>
responsesRoute.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })).model({ id }) responsesRoute
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
.model<OpenAIProviderOptionsInput>({ id })
const responsesWebSocket = (id: string | ModelID) => const responsesWebSocket = (id: string | ModelID) =>
responsesWebSocketRoute.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })).model({ id }) responsesWebSocketRoute
const chat = (id: string | ModelID) => chatRoute.with(withOpenAIOptions(id, modelDefaults)).model({ id }) .with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
.model<OpenAIProviderOptionsInput>({ id })
const chat = (id: string | ModelID) =>
chatRoute.with(withOpenAIOptions(id, modelDefaults)).model<OpenAIProviderOptionsInput>({ id })
const image = (modelID: string | ModelID) => const image = (modelID: string | ModelID) =>
OpenAIImages.model({ OpenAIImages.model({
id: modelID, id: modelID,
@@ -132,15 +137,17 @@ const config = (settings: Settings): Config => {
} }
} }
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => { export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
const configured = configure(config(settings)) const configured = configure(config(settings))
if (settings.transport === undefined || settings.transport === "http") return configured.responses(modelID) if (settings.transport === undefined || settings.transport === "http") return configured.responses(modelID)
if (settings.transport === "websocket") return configured.responsesWebSocket(modelID) if (settings.transport === "websocket") return configured.responsesWebSocket(modelID)
throw new Error(`Unsupported OpenAI Responses transport: ${String(settings.transport)}`) throw new Error(`Unsupported OpenAI Responses transport: ${String(settings.transport)}`)
} }
export const chatModel: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
configure(config(settings)).chat(modelID) modelID,
settings,
) => configure(config(settings)).chat(modelID)
export const responses = provider.responses export const responses = provider.responses
export const responsesWebSocket = provider.responsesWebSocket export const responsesWebSocket = provider.responsesWebSocket
export const chat = provider.chat export const chat = provider.chat
+107 -15
View File
@@ -4,32 +4,90 @@ import { Endpoint } from "../route/endpoint"
import { Framing } from "../route/framing" import { Framing } from "../route/framing"
import { Protocol } from "../route/protocol" import { Protocol } from "../route/protocol"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import { ProviderID, type ModelID, type ProviderOptions } from "../schema" import { ProviderID, type CacheHint, type ModelID, type ProviderOptions } from "../schema"
import type { ProviderPackage } from "../provider-package"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile" import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
import * as OpenAIChat from "../protocols/openai-chat" import * as OpenAIChat from "../protocols/openai-chat"
import { newBreakpoints, ttlBucket } from "../protocols/utils/cache"
import { isRecord } from "../protocols/shared" import { isRecord } from "../protocols/shared"
export const profile = OpenAICompatibleProfiles.profiles.openrouter export const profile = OpenAICompatibleProfiles.profiles.openrouter
export const id = ProviderID.make(profile.provider) export const id = ProviderID.make(profile.provider)
const ADAPTER = "openrouter" const ADAPTER = "openrouter"
type OpenRouterString<Known extends string> = Known | (string & {})
export interface OpenRouterProviderRouting {
readonly [key: string]: unknown
readonly order?: ReadonlyArray<string>
readonly allow_fallbacks?: boolean
readonly require_parameters?: boolean
readonly data_collection?: OpenRouterString<"allow" | "deny">
readonly only?: ReadonlyArray<string>
readonly ignore?: ReadonlyArray<string>
readonly quantizations?: ReadonlyArray<string>
readonly sort?: OpenRouterString<"price" | "throughput" | "latency">
readonly max_price?: Readonly<{
prompt?: number | string
completion?: number | string
image?: number | string
audio?: number | string
request?: number | string
}>
readonly zdr?: boolean
}
export type OpenRouterPlugin =
| Readonly<{
id: "web"
max_results?: number
search_prompt?: string
engine?: OpenRouterString<"native" | "exa">
}>
| Readonly<{ id: "file-parser"; max_files?: number; pdf?: { engine?: string } }>
| Readonly<{ id: "moderation" }>
| Readonly<{ id: "response-healing" }>
| Readonly<{ id: "auto-router"; allowed_models?: ReadonlyArray<string> }>
| Readonly<{ id: string & {}; [key: string]: unknown }>
export interface OpenRouterOptions { export interface OpenRouterOptions {
readonly [key: string]: unknown readonly [key: string]: unknown
readonly usage?: boolean | Record<string, unknown> readonly debug?: Readonly<{ echo_upstream_body?: boolean }>
readonly reasoning?: Record<string, unknown> readonly models?: ReadonlyArray<string>
readonly plugins?: ReadonlyArray<OpenRouterPlugin>
readonly promptCacheKey?: string readonly promptCacheKey?: string
readonly provider?: OpenRouterProviderRouting
readonly reasoning?: Readonly<{
enabled?: boolean
exclude?: boolean
effort?: OpenRouterString<"none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max">
max_tokens?: number
}>
readonly usage?: boolean | Readonly<{ include: boolean }>
readonly user?: string
readonly web_search_options?: Readonly<{
max_results?: number
search_prompt?: string
engine?: OpenRouterString<"native" | "exa">
}>
} }
export type OpenRouterProviderOptionsInput = ProviderOptions & { export type OpenRouterProviderOptionsInput = ProviderOptions & {
readonly openrouter?: OpenRouterOptions readonly openrouter?: OpenRouterOptions
} }
export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> & export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & { ProviderAuthOption<"optional"> & {
readonly baseURL?: string readonly baseURL?: string
readonly providerOptions?: OpenRouterProviderOptionsInput readonly providerOptions?: OpenRouterProviderOptionsInput
} }
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: OpenRouterProviderOptionsInput
}
const OpenRouterBody = Schema.StructWithRest(Schema.Struct(OpenAIChat.bodyFields), [ const OpenRouterBody = Schema.StructWithRest(Schema.Struct(OpenAIChat.bodyFields), [
Schema.Record(Schema.String, Schema.Any), Schema.Record(Schema.String, Schema.Any),
]) ])
@@ -40,7 +98,7 @@ export const protocol = Protocol.make({
body: { body: {
schema: OpenRouterBody, schema: OpenRouterBody,
from: (request) => from: (request) =>
OpenAIChat.protocol.body.from(request).pipe( OpenAIChat.fromRequest(request, { cacheControl: cacheControl() }).pipe(
Effect.map((body) => { Effect.map((body) => {
const sourceAssistants = request.messages.filter((message) => message.role === "assistant") const sourceAssistants = request.messages.filter((message) => message.role === "assistant")
let assistantIndex = 0 let assistantIndex = 0
@@ -71,16 +129,39 @@ export const protocol = Protocol.make({
stream: OpenAIChat.protocol.stream, stream: OpenAIChat.protocol.stream,
}) })
const cacheControl = () => {
const breakpoints = newBreakpoints(4)
return (cache: CacheHint | undefined) => {
if (cache === undefined || breakpoints.remaining === 0) return undefined
breakpoints.remaining -= 1
return {
type: "ephemeral" as const,
...(ttlBucket(cache.ttlSeconds) === "1h" ? { ttl: "1h" } : {}),
}
}
}
const bodyOptions = (input: unknown) => { const bodyOptions = (input: unknown) => {
const openrouter = isRecord(input) ? input : {} const openrouter = isRecord(input) ? input : {}
const { usage, models, provider, plugins, web_search_options, debug, user, reasoning, promptCacheKey, ...options } =
openrouter
return { return {
...(openrouter.usage === true ...options,
...(usage === undefined || usage === true
? { usage: { include: true } } ? { usage: { include: true } }
: isRecord(openrouter.usage) : usage === false
? { usage: openrouter.usage } ? { usage: { include: false } }
: {}), : isRecord(usage)
...(isRecord(openrouter.reasoning) ? { reasoning: openrouter.reasoning } : {}), ? { usage }
...(typeof openrouter.promptCacheKey === "string" ? { prompt_cache_key: openrouter.promptCacheKey } : {}), : {}),
...(Array.isArray(models) ? { models } : {}),
...(isRecord(provider) ? { provider } : {}),
...(Array.isArray(plugins) ? { plugins } : {}),
...(isRecord(web_search_options) ? { web_search_options } : {}),
...(isRecord(debug) ? { debug } : {}),
...(typeof user === "string" ? { user } : {}),
...(isRecord(reasoning) ? { reasoning } : {}),
...(typeof promptCacheKey === "string" ? { prompt_cache_key: promptCacheKey } : {}),
} }
} }
@@ -94,7 +175,7 @@ export const route = Route.make({
export const routes = [route] export const routes = [route]
const configuredRoute = (input: ModelOptions) => { const configuredRoute = (input: LanguageModelOptions) => {
const { apiKey: _, auth: _auth, baseURL, ...rest } = input const { apiKey: _, auth: _auth, baseURL, ...rest } = input
return route.with({ return route.with({
...rest, ...rest,
@@ -103,14 +184,25 @@ const configuredRoute = (input: ModelOptions) => {
}) })
} }
export const configure = (input: ModelOptions = {}) => { export const configure = (input: LanguageModelOptions = {}) => {
const route = configuredRoute(input) const route = configuredRoute(input)
return { return {
id, id,
model: (modelID: string | ModelID) => route.model({ id: modelID }), model: (modelID: string | ModelID) => route.model<OpenRouterProviderOptionsInput>({ id: modelID }),
configure, configure,
} }
} }
export const provider = configure() export const provider = configure()
export const model = provider.model export const model: ProviderPackage.Definition<Settings, OpenRouterProviderOptionsInput>["model"] = (
modelID,
settings,
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
}).model(modelID)
+54 -14
View File
@@ -1,49 +1,81 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import type { RouteDefaultsInput } from "../route/client" import { Route, type RouteDefaultsInput } from "../route/client"
import { HttpOptions, ProviderID, type ModelID } from "../schema" import { Endpoint } from "../route/endpoint"
import { HttpOptions, ProviderID, type ModelID, type ProviderOptions } from "../schema"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile" import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat" import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat"
import * as OpenAIChat from "../protocols/openai-chat"
import * as OpenAIResponses from "../protocols/openai-responses" import * as OpenAIResponses from "../protocols/openai-responses"
import { XAIImages } from "../protocols/xai-images" import { XAIImages } from "../protocols/xai-images"
import type { OpenAIOptionsInput } from "./openai-options"
import type { ProviderPackage } from "../provider-package"
export const id = ProviderID.make("xai") export const id = ProviderID.make("xai")
export type ModelOptions = RouteDefaultsInput & export type XAIProviderOptionsInput = ProviderOptions & {
readonly xai?: OpenAIOptionsInput
}
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & { ProviderAuthOption<"optional"> & {
readonly baseURL?: string readonly baseURL?: string
readonly providerOptions?: XAIProviderOptionsInput
} }
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: XAIProviderOptionsInput
}
export type { XAIImageOptions } from "../protocols/xai-images" export type { XAIImageOptions } from "../protocols/xai-images"
export const routes = [OpenAIResponses.route, OpenAICompatibleChat.route] const responsesRoute = Route.make({
id: "openai-responses",
provider: id,
providerMetadataKey: "xai",
protocol: OpenAIResponses.protocol,
endpoint: Endpoint.path("/responses", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
transport: OpenAIResponses.httpTransport,
defaults: { providerOptions: { xai: { store: false } } },
})
const chatRoute = Route.make({
id: "openai-compatible-chat",
provider: id,
providerMetadataKey: "xai",
protocol: OpenAIChat.protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
transport: OpenAICompatibleChat.route.transport,
})
export const routes = [responsesRoute, chatRoute]
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "XAI_API_KEY") const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "XAI_API_KEY")
const configuredResponsesRoute = (input: ModelOptions) => { const configuredResponsesRoute = (input: LanguageModelOptions) => {
const { apiKey: _, auth: _auth, baseURL, ...rest } = input const { apiKey: _, auth: _auth, baseURL, ...rest } = input
return OpenAIResponses.route.with({ return responsesRoute.with({
...rest, ...rest,
provider: id,
endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL }, endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL },
auth: auth(input), auth: auth(input),
}) })
} }
const configuredChatRoute = (input: ModelOptions) => { const configuredChatRoute = (input: LanguageModelOptions) => {
const { apiKey: _, auth: _auth, baseURL, ...rest } = input const { apiKey: _, auth: _auth, baseURL, ...rest } = input
return OpenAICompatibleChat.route.with({ return chatRoute.with({
...rest, ...rest,
provider: id,
endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL }, endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL },
auth: auth(input), auth: auth(input),
}) })
} }
export const configure = (input: ModelOptions = {}) => { export const configure = (input: LanguageModelOptions = {}) => {
const responsesRoute = configuredResponsesRoute(input) const responsesRoute = configuredResponsesRoute(input)
const chatRoute = configuredChatRoute(input) const chatRoute = configuredChatRoute(input)
const responses = (modelID: string | ModelID) => responsesRoute.model({ id: modelID }) const responses = (modelID: string | ModelID) => responsesRoute.model<XAIProviderOptionsInput>({ id: modelID })
const chat = (modelID: string | ModelID) => chatRoute.model({ id: modelID }) const chat = (modelID: string | ModelID) => chatRoute.model<XAIProviderOptionsInput>({ id: modelID })
const image = (modelID: string | ModelID) => const image = (modelID: string | ModelID) =>
XAIImages.model({ XAIImages.model({
id: modelID, id: modelID,
@@ -63,7 +95,15 @@ export const configure = (input: ModelOptions = {}) => {
} }
export const provider = configure() export const provider = configure()
export const model = provider.model export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
}).model(modelID)
export const responses = provider.responses export const responses = provider.responses
export const chat = provider.chat export const chat = provider.chat
export const image = provider.image export const image = provider.image
+9 -5
View File
@@ -22,13 +22,17 @@ export type ProviderAuthOption<Mode extends ApiKeyMode> =
| AuthOverride | AuthOverride
| (Mode extends "optional" ? OptionalApiKeyAuth : RequiredApiKeyAuth) | (Mode extends "optional" ? OptionalApiKeyAuth : RequiredApiKeyAuth)
export type ModelOptions<Base, Mode extends ApiKeyMode> = Omit<Base, "apiKey" | "auth"> & ProviderAuthOption<Mode> export type LanguageModelOptions<Base, Mode extends ApiKeyMode> = Omit<Base, "apiKey" | "auth"> &
ProviderAuthOption<Mode>
export type ModelArgs<Base, Mode extends ApiKeyMode> = Mode extends "optional" export type LanguageModelArgs<Base, Mode extends ApiKeyMode> = Mode extends "optional"
? readonly [options?: ModelOptions<Base, Mode>] ? readonly [options?: LanguageModelOptions<Base, Mode>]
: readonly [options: ModelOptions<Base, Mode>] : readonly [options: LanguageModelOptions<Base, Mode>]
export type ModelFactory<Base, Mode extends ApiKeyMode, Model> = (id: string, ...args: ModelArgs<Base, Mode>) => Model export type LanguageModelFactory<Base, Mode extends ApiKeyMode, LanguageModel> = (
id: string,
...args: LanguageModelArgs<Base, Mode>
) => LanguageModel
/** /**
* Require at least one of the keys in `T`. Use for option shapes where any * Require at least one of the keys in `T`. Use for option shapes where any
+7 -7
View File
@@ -1,6 +1,6 @@
import { Config, Effect, Redacted } from "effect" import { Config, Effect, Redacted } from "effect"
import { Headers } from "effect/unstable/http" import { Headers } from "effect/unstable/http"
import { AuthenticationReason, InvalidRequestReason, LLMError, type HttpOptions } from "../schema" import { AuthenticationReason, InvalidRequestReason, AIError, type HttpOptions } from "../schema"
export class MissingCredentialError extends Error { export class MissingCredentialError extends Error {
readonly _tag = "MissingCredentialError" readonly _tag = "MissingCredentialError"
@@ -11,7 +11,7 @@ export class MissingCredentialError extends Error {
} }
export type CredentialError = MissingCredentialError | Config.ConfigError export type CredentialError = MissingCredentialError | Config.ConfigError
export type AuthError = CredentialError | LLMError export type AuthError = CredentialError | AIError
type Secret = string | Redacted.Redacted | Config.Config<string | Redacted.Redacted> type Secret = string | Redacted.Redacted | Config.Config<string | Redacted.Redacted>
export interface AuthInput { export interface AuthInput {
@@ -100,7 +100,7 @@ export const headers = (input: Headers.Input) =>
export const remove = (name: string) => auth((input) => Effect.succeed(Headers.remove(input.headers, name))) export const remove = (name: string) => auth((input) => Effect.succeed(Headers.remove(input.headers, name)))
export const custom = (apply: (input: AuthInput) => Effect.Effect<Headers.Headers, LLMError>) => auth(apply) export const custom = (apply: (input: AuthInput) => Effect.Effect<Headers.Headers, AIError>) => auth(apply)
export const passthrough = none export const passthrough = none
@@ -134,9 +134,9 @@ export function bearerHeader(name: string, source?: Secret | Credential) {
return render(source) return render(source)
} }
const toLLMError = (error: AuthError): LLMError => { const toAIError = (error: AuthError): AIError => {
if (error instanceof MissingCredentialError || error instanceof Config.ConfigError) { if (error instanceof MissingCredentialError || error instanceof Config.ConfigError) {
return new LLMError({ return new AIError({
module: "Auth", module: "Auth",
method: "apply", method: "apply",
reason: reason:
@@ -150,7 +150,7 @@ const toLLMError = (error: AuthError): LLMError => {
export const toEffect = export const toEffect =
(input: Definition) => (input: Definition) =>
(authInput: AuthInput): Effect.Effect<Headers.Headers, LLMError> => (authInput: AuthInput): Effect.Effect<Headers.Headers, AIError> =>
input.apply(authInput).pipe(Effect.mapError(toLLMError)) input.apply(authInput).pipe(Effect.mapError(toAIError))
export * as Auth from "./auth" export * as Auth from "./auth"
+57 -61
View File
@@ -5,22 +5,21 @@ import { Endpoint, type EndpointPatch } from "./endpoint"
import { RequestExecutor } from "./executor" import { RequestExecutor } from "./executor"
import { Framing } from "./framing" import { Framing } from "./framing"
import { HttpTransport } from "./transport" import { HttpTransport } from "./transport"
import type { Transport, TransportRuntime } from "./transport" import type { HttpRequestTransform, Transport, TransportRuntime } from "./transport"
import { WebSocketExecutor } from "./transport" import { WebSocketExecutor } from "./transport"
import type { Protocol } from "./protocol" import type { Protocol } from "./protocol"
import { applyCachePolicy } from "../cache-policy" import { applyCachePolicy } from "../cache-policy"
import * as ProviderShared from "../protocols/shared" import * as ProviderShared from "../protocols/shared"
import type { LLMError, PreparedRequestOf, ProtocolID, ProviderOptions } from "../schema" import type { ProtocolID, ProviderOptions } from "../schema"
import { import {
AIError,
GenerationOptions, GenerationOptions,
HttpOptions, HttpOptions,
LLMRequest, LLMRequest,
LLMResponse, LLMResponse,
Model, LanguageModel,
ModelLimits, LanguageModelLimits,
LLMError as LLMErrorClass,
LLMEvent, LLMEvent,
PreparedRequest,
ProviderID, ProviderID,
mergeGenerationOptions, mergeGenerationOptions,
mergeHttpOptions, mergeHttpOptions,
@@ -31,7 +30,7 @@ export interface RouteBody<Body> {
/** Schema for the validated provider-native body sent as the JSON request. */ /** Schema for the validated provider-native body sent as the JSON request. */
readonly schema: Schema.Codec<Body, unknown> readonly schema: Schema.Codec<Body, unknown>
/** Build the provider-native body from a common `LLMRequest`. */ /** Build the provider-native body from a common `LLMRequest`. */
readonly from: (request: LLMRequest) => Effect.Effect<Body, LLMError> readonly from: (request: LLMRequest) => Effect.Effect<Body, AIError>
} }
export interface Route<Body, Prepared = unknown> { export interface Route<Body, Prepared = unknown> {
@@ -46,13 +45,19 @@ export interface Route<Body, Prepared = unknown> {
readonly defaults: RouteDefaults readonly defaults: RouteDefaults
readonly body: RouteBody<Body> readonly body: RouteBody<Body>
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared> readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared>
readonly model: (input: RouteMappedModelInput) => Model readonly model: <Options extends ProviderOptions = ProviderOptions>(
readonly prepareTransport: (body: Body, request: LLMRequest) => Effect.Effect<Prepared, LLMError> input: RouteMappedLanguageModelInput,
) => LanguageModel<Options>
readonly prepareTransport: (
body: Body,
request: LLMRequest,
options?: StreamOptions,
) => Effect.Effect<Prepared, AIError>
readonly streamPrepared: ( readonly streamPrepared: (
prepared: Prepared, prepared: Prepared,
request: LLMRequest, request: LLMRequest,
runtime: TransportRuntime, runtime: TransportRuntime,
) => Stream.Stream<LLMEvent, LLMError> ) => Stream.Stream<LLMEvent, AIError>
} }
// Route registries intentionally erase body generics after construction. // Route registries intentionally erase body generics after construction.
@@ -63,13 +68,13 @@ export type AnyRoute = Route<any, any>
export type HttpOptionsInput = HttpOptions.Input export type HttpOptionsInput = HttpOptions.Input
export type RouteModelInput = Omit<Model.Input, "provider" | "route"> export type RouteLanguageModelInput = Omit<LanguageModel.Input, "provider" | "route">
export type RouteRoutedModelInput = Omit<Model.Input, "route"> export type RouteRoutedLanguageModelInput = Omit<LanguageModel.Input, "route">
export interface RouteDefaults { export interface RouteDefaults {
readonly headers?: Record<string, string> readonly headers?: Record<string, string>
readonly limits?: ModelLimits readonly limits?: LanguageModelLimits
readonly generation?: GenerationOptions readonly generation?: GenerationOptions
readonly providerOptions?: ProviderOptions readonly providerOptions?: ProviderOptions
readonly http?: HttpOptions readonly http?: HttpOptions
@@ -77,7 +82,7 @@ export interface RouteDefaults {
export interface RouteDefaultsInput { export interface RouteDefaultsInput {
readonly headers?: Record<string, string> readonly headers?: Record<string, string>
readonly limits?: ModelLimits.Input readonly limits?: LanguageModelLimits.Input
readonly generation?: GenerationOptions.Input readonly generation?: GenerationOptions.Input
readonly providerOptions?: ProviderOptions readonly providerOptions?: ProviderOptions
readonly http?: HttpOptions.Input readonly http?: HttpOptions.Input
@@ -91,14 +96,17 @@ export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
readonly endpoint?: EndpointPatch<Body> readonly endpoint?: EndpointPatch<Body>
} }
type RouteMappedModelInput = RouteModelInput | RouteRoutedModelInput type RouteMappedLanguageModelInput = RouteLanguageModelInput | RouteRoutedLanguageModelInput
const makeRouteModel = (route: AnyRoute, mapped: RouteMappedModelInput) => { const makeRouteLanguageModel = <Options extends ProviderOptions = ProviderOptions>(
route: AnyRoute,
mapped: RouteMappedLanguageModelInput,
) => {
const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined) const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined)
if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`) if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
if (!endpointBaseURL(route.endpoint)) if (!endpointBaseURL(route.endpoint))
throw new Error(`Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`) throw new Error(`Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`)
return Model.make({ return LanguageModel.make<Options>({
...mapped, ...mapped,
provider, provider,
route, route,
@@ -111,7 +119,7 @@ const mergeRouteDefaults = (base: RouteDefaults | undefined, patch: RouteDefault
...base, ...base,
...patch, ...patch,
headers, headers,
limits: patch.limits === undefined ? base?.limits : ModelLimits.make(patch.limits), limits: patch.limits === undefined ? base?.limits : LanguageModelLimits.make(patch.limits),
generation: mergeGenerationOptions(generationOptions(base?.generation), generationOptions(patch.generation)), generation: mergeGenerationOptions(generationOptions(base?.generation), generationOptions(patch.generation)),
providerOptions: mergeProviderOptions(base?.providerOptions, patch.providerOptions), providerOptions: mergeProviderOptions(base?.providerOptions, patch.providerOptions),
http: mergeHttpOptions( http: mergeHttpOptions(
@@ -142,27 +150,20 @@ export const httpOptions = (input: HttpOptionsInput | undefined) => {
} }
export interface Interface { export interface Interface {
/**
* Compile a request through protocol body construction, validation, and HTTP
* preparation without sending it. Returns the prepared request including the
* provider-native body.
*
* Pass a `Body` type argument to statically expose the route's body
* shape (e.g. `prepare<OpenAIChatBody>(...)`) — the runtime body is
* identical, so this is a type-level assertion the caller makes about which
* route the request will resolve to.
*/
readonly prepare: <Body = unknown>(request: LLMRequest) => Effect.Effect<PreparedRequestOf<Body>, LLMError>
readonly stream: StreamMethod readonly stream: StreamMethod
readonly generate: GenerateMethod readonly generate: GenerateMethod
} }
export interface StreamOptions {
readonly transform?: HttpRequestTransform
}
export interface StreamMethod { export interface StreamMethod {
(request: LLMRequest): Stream.Stream<LLMEvent, LLMError> (request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, AIError>
} }
export interface GenerateMethod { export interface GenerateMethod {
(request: LLMRequest): Effect.Effect<LLMResponse, LLMError> (request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, AIError>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {} export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
@@ -226,11 +227,11 @@ export interface MakeTransportInput<Body, Prepared, Frame, Event, State> {
const streamError = (route: string, message: string, cause: Cause.Cause<unknown>) => { const streamError = (route: string, message: string, cause: Cause.Cause<unknown>) => {
const failed = cause.reasons.find(Cause.isFailReason)?.error const failed = cause.reasons.find(Cause.isFailReason)?.error
if (failed instanceof LLMErrorClass) return failed if (failed instanceof AIError) return failed
return ProviderShared.eventError(route, message, Cause.pretty(cause)) return ProviderShared.eventError(route, message, Cause.pretty(cause))
} }
const requireTerminalEvent = (route: string) => (events: Stream.Stream<LLMEvent, LLMError>) => const requireTerminalEvent = (route: string) => (events: Stream.Stream<LLMEvent, AIError>) =>
Stream.suspend(() => { Stream.suspend(() => {
let terminal = false let terminal = false
return events.pipe( return events.pipe(
@@ -296,8 +297,9 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
defaults: mergeRouteDefaults(route.defaults, defaults), defaults: mergeRouteDefaults(route.defaults, defaults),
}) })
}, },
model: (input) => makeRouteModel(route, input), model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedLanguageModelInput) =>
prepareTransport: (body, request) => makeRouteLanguageModel<Options>(route, input),
prepareTransport: (body, request, options) =>
routeInput.transport.prepare({ routeInput.transport.prepare({
body, body,
request, request,
@@ -305,6 +307,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
auth: routeInput.auth ?? Auth.none, auth: routeInput.auth ?? Auth.none,
encodeBody, encodeBody,
headers: routeInput.headers, headers: routeInput.headers,
transform: options?.transform,
}), }),
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => { streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
const route = `${request.model.provider}/${request.model.route.id}` const route = `${request.model.provider}/${request.model.route.id}`
@@ -370,17 +373,14 @@ export function make<Body, Prepared, Frame, Event, State>(
}) })
} }
// `compile` is the important boundary: it turns a common `LLMRequest` into a const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: StreamOptions) {
// validated provider body plus transport-private prepared data, but does not
// execute transport.
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
const resolved = applyCachePolicy(resolveRequestOptions(request)) const resolved = applyCachePolicy(resolveRequestOptions(request))
const route = resolved.model.route const route = resolved.model.route
const body = yield* route.body const body = yield* route.body
.from(resolved) .from(resolved)
.pipe(Effect.flatMap(ProviderShared.validateWith(Schema.decodeUnknownEffect(route.body.schema)))) .pipe(Effect.flatMap(ProviderShared.validateWith(Schema.decodeUnknownEffect(route.body.schema))))
const prepared = yield* route.prepareTransport(body, resolved) const prepared = yield* route.prepareTransport(body, resolved, options)
return { return {
request: resolved, request: resolved,
@@ -390,30 +390,30 @@ const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
} }
}) })
const prepareWith = Effect.fn("LLMClient.prepare")(function* (request: LLMRequest) { /** @internal Test-only projection of the execution compiler; not exported from package barrels. */
export const compileRequest = Effect.fn("LLM.compileRequest")(function* (request: LLMRequest) {
const compiled = yield* compile(request) const compiled = yield* compile(request)
return {
return new PreparedRequest({
id: compiled.request.id ?? "request", id: compiled.request.id ?? "request",
route: compiled.route.id, route: compiled.route.id,
protocol: compiled.route.protocol, protocol: compiled.route.protocol,
model: compiled.request.model, model: compiled.request.model,
body: compiled.body, body: compiled.body,
metadata: { transport: compiled.route.transport.id }, metadata: { transport: compiled.route.transport.id },
}) }
}) })
const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) => const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest, options?: StreamOptions) =>
Stream.unwrap( Stream.unwrap(
Effect.gen(function* () { Effect.gen(function* () {
const compiled = yield* compile(request) const compiled = yield* compile(request, options)
return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime) return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime)
}), }),
) )
const generateWith = (stream: Interface["stream"]) => const generateWith = (stream: Interface["stream"]) =>
Effect.fn("LLM.generate")(function* (request: LLMRequest) { Effect.fn("LLM.generate")(function* (request: LLMRequest, options?: StreamOptions) {
const state = yield* stream(request).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce)) const state = yield* stream(request, options).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
const response = LLMResponse.complete(state) const response = LLMResponse.complete(state)
if (response) return response if (response) return response
return yield* ProviderShared.eventError( return yield* ProviderShared.eventError(
@@ -422,27 +422,24 @@ const generateWith = (stream: Interface["stream"]) =>
) )
}) })
export const prepare = <Body = unknown>(request: LLMRequest) => export function stream(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, AIError> {
prepareWith(request) as Effect.Effect<PreparedRequestOf<Body>, LLMError>
export function stream(request: LLMRequest): Stream.Stream<LLMEvent, LLMError> {
return Stream.unwrap( return Stream.unwrap(
Effect.gen(function* () { Effect.gen(function* () {
return (yield* Service).stream(request) return (yield* Service).stream(request, options)
}), }),
) as Stream.Stream<LLMEvent, LLMError> ) as Stream.Stream<LLMEvent, AIError>
} }
export function generate(request: LLMRequest): Effect.Effect<LLMResponse, LLMError> { export function generate(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, AIError> {
return Effect.gen(function* () { return Effect.gen(function* () {
return yield* (yield* Service).generate(request) return yield* (yield* Service).generate(request, options)
}) as Effect.Effect<LLMResponse, LLMError> }) as Effect.Effect<LLMResponse, AIError>
} }
export const streamRequest = (request: LLMRequest) => export const streamRequest = (request: LLMRequest, options?: StreamOptions) =>
Stream.unwrap( Stream.unwrap(
Effect.gen(function* () { Effect.gen(function* () {
return (yield* Service).stream(request) return (yield* Service).stream(request, options)
}), }),
) )
@@ -453,7 +450,7 @@ export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer
http: yield* RequestExecutor.Service, http: yield* RequestExecutor.Service,
webSocket: Option.getOrUndefined(yield* Effect.serviceOption(WebSocketExecutor.Service)), webSocket: Option.getOrUndefined(yield* Effect.serviceOption(WebSocketExecutor.Service)),
}) })
return Service.of({ prepare: prepareWith as Interface["prepare"], stream, generate: generateWith(stream) }) return Service.of({ stream, generate: generateWith(stream) })
}), }),
) )
@@ -462,7 +459,6 @@ export const Route = { make } as const
export const LLMClient = { export const LLMClient = {
Service, Service,
layer, layer,
prepare,
stream, stream,
generate, generate,
} as const } as const
+5 -5
View File
@@ -12,7 +12,7 @@ import {
HttpRateLimitDetails, HttpRateLimitDetails,
HttpRequestDetails, HttpRequestDetails,
HttpResponseDetails, HttpResponseDetails,
LLMError, AIError,
TransportReason, TransportReason,
} from "../schema" } from "../schema"
import { classifyProviderFailure } from "../provider-error" import { classifyProviderFailure } from "../provider-error"
@@ -20,10 +20,10 @@ import { classifyProviderFailure } from "../provider-error"
export interface Interface { export interface Interface {
readonly execute: ( readonly execute: (
request: HttpClientRequest.HttpClientRequest, request: HttpClientRequest.HttpClientRequest,
) => Effect.Effect<HttpClientResponse.HttpClientResponse, LLMError> ) => Effect.Effect<HttpClientResponse.HttpClientResponse, AIError>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM/RequestExecutor") {} export class Service extends Context.Service<Service, Interface>()("@opencode/AI/RequestExecutor") {}
const BODY_LIMIT = 16_384 const BODY_LIMIT = 16_384
const REDACTED = "<redacted>" const REDACTED = "<redacted>"
@@ -220,7 +220,7 @@ const statusError =
const retryAfter = retryAfterMs(headers) const retryAfter = retryAfterMs(headers)
const rateLimit = rateLimitDetails(headers, retryAfter) const rateLimit = rateLimitDetails(headers, retryAfter)
const details = responseBody(body, request) const details = responseBody(body, request)
return yield* new LLMError({ return yield* new AIError({
module: "RequestExecutor", module: "RequestExecutor",
method: "execute", method: "execute",
reason: classifyProviderFailure({ reason: classifyProviderFailure({
@@ -246,7 +246,7 @@ const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: u
readonly kind?: string | undefined readonly kind?: string | undefined
readonly request?: HttpClientRequest.HttpClientRequest | undefined readonly request?: HttpClientRequest.HttpClientRequest | undefined
}) => }) =>
new LLMError({ new AIError({
module: "RequestExecutor", module: "RequestExecutor",
method: "execute", method: "execute",
reason: new TransportReason({ reason: new TransportReason({
+2 -2
View File
@@ -1,6 +1,6 @@
import type { Stream } from "effect" import type { Stream } from "effect"
import * as ProviderShared from "../protocols/shared" import * as ProviderShared from "../protocols/shared"
import type { LLMError } from "../schema" import type { AIError } from "../schema"
/** /**
* Decode a streaming HTTP response body into provider-protocol frames. * Decode a streaming HTTP response body into provider-protocol frames.
@@ -18,7 +18,7 @@ import type { LLMError } from "../schema"
*/ */
export interface Definition<Frame> { export interface Definition<Frame> {
readonly id: string readonly id: string
readonly frame: (bytes: Stream.Stream<Uint8Array, LLMError>) => Stream.Stream<Frame, LLMError> readonly frame: (bytes: Stream.Stream<Uint8Array, AIError>) => Stream.Stream<Frame, AIError>
} }
/** Server-Sent Events framing. Used by every JSON-streaming HTTP provider. */ /** Server-Sent Events framing. Used by every JSON-streaming HTTP provider. */
+4 -3
View File
@@ -1,13 +1,14 @@
export { Route, LLMClient } from "./client" export { Route, LLMClient } from "./client"
export type { export type {
Route as RouteShape, Route as RouteShape,
RouteModelInput, RouteLanguageModelInput,
RouteRoutedModelInput, RouteRoutedLanguageModelInput,
RouteDefaults, RouteDefaults,
RouteDefaultsInput, RouteDefaultsInput,
AnyRoute, AnyRoute,
Interface as LLMClientShape, Interface as LLMClientShape,
Service as LLMClientService, Service as LLMClientService,
StreamOptions,
} from "./client" } from "./client"
export * from "./executor" export * from "./executor"
export { Auth } from "./auth" export { Auth } from "./auth"
@@ -22,4 +23,4 @@ export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-option
export type { Definition as EndpointFn, EndpointInput } from "./endpoint" export type { Definition as EndpointFn, EndpointInput } from "./endpoint"
export type { Definition as FramingDef } from "./framing" export type { Definition as FramingDef } from "./framing"
export type { Protocol as ProtocolDef } from "./protocol" export type { Protocol as ProtocolDef } from "./protocol"
export type { Transport as TransportDef, TransportRuntime } from "./transport" export type { HttpRequest, HttpRequestTransform, Transport as TransportDef, TransportRuntime } from "./transport"
+3 -3
View File
@@ -1,5 +1,5 @@
import { Schema, type Effect } from "effect" import { Schema, type Effect } from "effect"
import type { LLMError, LLMEvent, LLMRequest, ProtocolID } from "../schema" import type { AIError, LLMEvent, LLMRequest, ProtocolID } from "../schema"
/** /**
* The semantic API contract of one model server family. * The semantic API contract of one model server family.
@@ -47,7 +47,7 @@ export interface ProtocolBody<Body> {
/** Schema for the validated provider-native body sent as the JSON request. */ /** Schema for the validated provider-native body sent as the JSON request. */
readonly schema: Schema.Codec<Body, unknown> readonly schema: Schema.Codec<Body, unknown>
/** Build the provider-native body from a common `LLMRequest`. */ /** Build the provider-native body from a common `LLMRequest`. */
readonly from: (request: LLMRequest) => Effect.Effect<Body, LLMError> readonly from: (request: LLMRequest) => Effect.Effect<Body, AIError>
} }
export interface ProtocolStream<Frame, Event, State> { export interface ProtocolStream<Frame, Event, State> {
@@ -56,7 +56,7 @@ export interface ProtocolStream<Frame, Event, State> {
/** Initial parser state. Called once per response with the resolved request. */ /** Initial parser state. Called once per response with the resolved request. */
readonly initial: (request: LLMRequest) => State readonly initial: (request: LLMRequest) => State
/** Translate one event into emitted `LLMEvent`s plus the next state. */ /** Translate one event into emitted `LLMEvent`s plus the next state. */
readonly step: (state: State, event: Event) => Effect.Effect<readonly [State, ReadonlyArray<LLMEvent>], LLMError> readonly step: (state: State, event: Event) => Effect.Effect<readonly [State, ReadonlyArray<LLMEvent>], AIError>
/** Optional request-completion signal for transports that do not end naturally. */ /** Optional request-completion signal for transports that do not end naturally. */
readonly terminal?: (event: Event) => boolean readonly terminal?: (event: Event) => boolean
/** Optional flush emitted when the framed stream ends. */ /** Optional flush emitted when the framed stream ends. */
+12 -55
View File
@@ -28,57 +28,9 @@ const applyQuery = (url: string, query: Record<string, string> | undefined) => {
return next.toString() return next.toString()
} }
const PROTOCOL_BODY_OVERLAY_DENYLIST = new Set([
"anthropic_version",
"content",
"contents",
"frequencyPenalty",
"frequency_penalty",
"generationConfig",
"inferenceConfig",
"input",
"maxTokens",
"max_tokens",
"messages",
"model",
"presencePenalty",
"presence_penalty",
"responseFormat",
"response_format",
"seed",
"stop",
"stopSequences",
"stop_sequences",
"stream",
"streamOptions",
"stream_options",
"system",
"systemInstruction",
"system_instruction",
"temperature",
"thinking",
"toolChoice",
"toolConfig",
"tool_choice",
"tool_config",
"tools",
"topK",
"topP",
"top_k",
"top_p",
])
const forbiddenBodyOverlayKeys = (body: Record<string, unknown>) =>
Object.keys(body).filter((key) => PROTOCOL_BODY_OVERLAY_DENYLIST.has(key))
const bodyWithOverlay = <Body>(body: Body, request: LLMRequest, encodeBody: (body: Body) => string) => const bodyWithOverlay = <Body>(body: Body, request: LLMRequest, encodeBody: (body: Body) => string) =>
Effect.gen(function* () { Effect.gen(function* () {
if (request.http?.body === undefined) return { jsonBody: body, bodyText: encodeBody(body) } if (request.http?.body === undefined) return { jsonBody: body, bodyText: encodeBody(body) }
const forbiddenKeys = forbiddenBodyOverlayKeys(request.http.body)
if (forbiddenKeys.length > 0)
return yield* ProviderShared.invalidRequest(
`http.body cannot overlay protocol-owned field(s): ${forbiddenKeys.join(", ")}`,
)
if (ProviderShared.isRecord(body)) { if (ProviderShared.isRecord(body)) {
const overlaid = mergeJsonRecords(body, request.http.body) ?? {} const overlaid = mergeJsonRecords(body, request.http.body) ?? {}
return { jsonBody: overlaid, bodyText: ProviderShared.encodeJson(overlaid) } return { jsonBody: overlaid, bodyText: ProviderShared.encodeJson(overlaid) }
@@ -120,14 +72,19 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
id: "http-json", id: "http-json",
with: (patch) => httpJson({ ...input, ...patch }), with: (patch) => httpJson({ ...input, ...patch }),
prepare: (prepareInput) => prepare: (prepareInput) =>
jsonRequestParts({ Effect.gen(function* () {
...prepareInput, const parts = yield* jsonRequestParts({ ...prepareInput })
}).pipe( const request = { url: parts.url, method: "POST", headers: { ...parts.headers }, body: parts.bodyText }
Effect.map((parts) => ({ yield* (prepareInput.transform?.(request) ?? Effect.void)
request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }), return {
request: ProviderShared.jsonPost({
url: request.url,
body: request.body ?? "",
headers: Headers.fromInput(request.headers),
}),
framing: input.framing, framing: input.framing,
})), }
), }),
frames: (prepared, request, runtime) => frames: (prepared, request, runtime) =>
Stream.unwrap( Stream.unwrap(
runtime.http runtime.http
+13 -7
View File
@@ -3,21 +3,26 @@ import { Endpoint } from "../endpoint"
import { Auth } from "../auth" import { Auth } from "../auth"
import type { Interface as RequestExecutorInterface } from "../executor" import type { Interface as RequestExecutorInterface } from "../executor"
import type { Interface as WebSocketExecutorInterface } from "./websocket" import type { Interface as WebSocketExecutorInterface } from "./websocket"
import type { LLMError, LLMRequest } from "../../schema" import type { AIError, LLMRequest } from "../../schema"
export interface TransportRuntime { export interface TransportRuntime {
readonly http: RequestExecutorInterface readonly http: RequestExecutorInterface
readonly webSocket?: WebSocketExecutorInterface readonly webSocket?: WebSocketExecutorInterface
} }
export interface HttpRequest {
url: string
readonly method: string
headers: Record<string, string>
body: string | undefined
}
export type HttpRequestTransform = (request: HttpRequest) => Effect.Effect<void>
export interface Transport<Body, Prepared, Frame> { export interface Transport<Body, Prepared, Frame> {
readonly id: string readonly id: string
readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, LLMError> readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, AIError>
readonly frames: ( readonly frames: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => Stream.Stream<Frame, AIError>
prepared: Prepared,
request: LLMRequest,
runtime: TransportRuntime,
) => Stream.Stream<Frame, LLMError>
} }
export interface TransportPrepareInput<Body> { export interface TransportPrepareInput<Body> {
@@ -27,6 +32,7 @@ export interface TransportPrepareInput<Body> {
readonly auth: Auth.Definition readonly auth: Auth.Definition
readonly encodeBody: (body: Body) => string readonly encodeBody: (body: Body) => string
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string> readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
readonly transform?: HttpRequestTransform
} }
export * as HttpTransport from "./http" export * as HttpTransport from "./http"
+10 -10
View File
@@ -1,6 +1,6 @@
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect" import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
import { Headers } from "effect/unstable/http" import { Headers } from "effect/unstable/http"
import { LLMError, TransportReason } from "../../schema" import { AIError, TransportReason } from "../../schema"
import * as HttpTransport from "./http" import * as HttpTransport from "./http"
import type { Transport } from "./index" import type { Transport } from "./index"
@@ -10,13 +10,13 @@ export interface WebSocketRequest {
} }
export interface WebSocketConnection { export interface WebSocketConnection {
readonly sendText: (message: string) => Effect.Effect<void, LLMError> readonly sendText: (message: string) => Effect.Effect<void, AIError>
readonly messages: Stream.Stream<string | Uint8Array, LLMError> readonly messages: Stream.Stream<string | Uint8Array, AIError>
readonly close: Effect.Effect<void, never> readonly close: Effect.Effect<void, never>
} }
export interface Interface { export interface Interface {
readonly open: (input: WebSocketRequest) => Effect.Effect<WebSocketConnection, LLMError> readonly open: (input: WebSocketRequest) => Effect.Effect<WebSocketConnection, AIError>
} }
type WebSocketConstructorWithHeaders = new ( type WebSocketConstructorWithHeaders = new (
@@ -24,14 +24,14 @@ type WebSocketConstructorWithHeaders = new (
options?: { readonly headers?: Headers.Headers }, options?: { readonly headers?: Headers.Headers },
) => globalThis.WebSocket ) => globalThis.WebSocket
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM/WebSocketExecutor") {} export class Service extends Context.Service<Service, Interface>()("@opencode/AI/WebSocketExecutor") {}
const transportError = ( const transportError = (
method: string, method: string,
message: string, message: string,
input: { readonly url?: string; readonly kind?: string } = {}, input: { readonly url?: string; readonly kind?: string } = {},
) => ) =>
new LLMError({ new AIError({
module: "WebSocketExecutor", module: "WebSocketExecutor",
method, method,
reason: new TransportReason({ message, url: input.url, kind: input.kind }), reason: new TransportReason({ message, url: input.url, kind: input.kind }),
@@ -59,7 +59,7 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
}), }),
) )
} }
return Effect.callback<void, LLMError>((resume, signal) => { return Effect.callback<void, AIError>((resume, signal) => {
const cleanup = () => { const cleanup = () => {
ws.removeEventListener("open", onOpen) ws.removeEventListener("open", onOpen)
ws.removeEventListener("error", onError) ws.removeEventListener("error", onError)
@@ -138,10 +138,10 @@ export const layer: Layer.Layer<Service> = Layer.succeed(Service, Service.of({ o
export const fromWebSocket = ( export const fromWebSocket = (
ws: globalThis.WebSocket, ws: globalThis.WebSocket,
input: WebSocketRequest, input: WebSocketRequest,
): Effect.Effect<WebSocketConnection, LLMError> => ): Effect.Effect<WebSocketConnection, AIError> =>
Effect.gen(function* () { Effect.gen(function* () {
yield* waitOpen(ws, input) yield* waitOpen(ws, input)
const messages = yield* Queue.bounded<string | Uint8Array, LLMError | Cause.Done<void>>(128) const messages = yield* Queue.bounded<string | Uint8Array, AIError | Cause.Done<void>>(128)
const onMessage = (event: MessageEvent) => { const onMessage = (event: MessageEvent) => {
if (typeof event.data === "string") return Queue.offerUnsafe(messages, event.data) if (typeof event.data === "string") return Queue.offerUnsafe(messages, event.data)
@@ -213,7 +213,7 @@ export interface JsonPrepared {
} }
export interface JsonInput<Body, Message> { export interface JsonInput<Body, Message> {
readonly toMessage: (body: Body | Record<string, unknown>) => Effect.Effect<Message, LLMError> readonly toMessage: (body: Body | Record<string, unknown>) => Effect.Effect<Message, AIError>
readonly encodeMessage: (message: Message) => string readonly encodeMessage: (message: Message) => string
} }
+20 -20
View File
@@ -2,28 +2,28 @@ import { Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool" import { Tool } from "@opencode-ai/schema/tool"
import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids" import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids"
export const ProviderFailureClassification = Schema.Literal("context-overflow") export const ProviderFailureClassification = Schema.Literals(["context-overflow", "payload-too-large"])
export type ProviderFailureClassification = typeof ProviderFailureClassification.Type export type ProviderFailureClassification = typeof ProviderFailureClassification.Type
export class HttpRequestDetails extends Schema.Class<HttpRequestDetails>("LLM.HttpRequestDetails")({ export class HttpRequestDetails extends Schema.Class<HttpRequestDetails>("AI.HttpRequestDetails")({
method: Schema.String, method: Schema.String,
url: Schema.String, url: Schema.String,
headers: Schema.Record(Schema.String, Schema.String), headers: Schema.Record(Schema.String, Schema.String),
}) {} }) {}
export class HttpResponseDetails extends Schema.Class<HttpResponseDetails>("LLM.HttpResponseDetails")({ export class HttpResponseDetails extends Schema.Class<HttpResponseDetails>("AI.HttpResponseDetails")({
status: Schema.Number, status: Schema.Number,
headers: Schema.Record(Schema.String, Schema.String), headers: Schema.Record(Schema.String, Schema.String),
}) {} }) {}
export class HttpRateLimitDetails extends Schema.Class<HttpRateLimitDetails>("LLM.HttpRateLimitDetails")({ export class HttpRateLimitDetails extends Schema.Class<HttpRateLimitDetails>("AI.HttpRateLimitDetails")({
retryAfterMs: Schema.optional(Schema.Number), retryAfterMs: Schema.optional(Schema.Number),
limit: Schema.optional(Schema.Record(Schema.String, Schema.String)), limit: Schema.optional(Schema.Record(Schema.String, Schema.String)),
remaining: Schema.optional(Schema.Record(Schema.String, Schema.String)), remaining: Schema.optional(Schema.Record(Schema.String, Schema.String)),
reset: Schema.optional(Schema.Record(Schema.String, Schema.String)), reset: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {} }) {}
export class HttpContext extends Schema.Class<HttpContext>("LLM.HttpContext")({ export class HttpContext extends Schema.Class<HttpContext>("AI.HttpContext")({
request: HttpRequestDetails, request: HttpRequestDetails,
response: Schema.optional(HttpResponseDetails), response: Schema.optional(HttpResponseDetails),
body: Schema.optional(Schema.String), body: Schema.optional(Schema.String),
@@ -32,7 +32,7 @@ export class HttpContext extends Schema.Class<HttpContext>("LLM.HttpContext")({
rateLimit: Schema.optional(HttpRateLimitDetails), rateLimit: Schema.optional(HttpRateLimitDetails),
}) {} }) {}
export class InvalidRequestReason extends Schema.Class<InvalidRequestReason>("LLM.Error.InvalidRequest")({ export class InvalidRequestReason extends Schema.Class<InvalidRequestReason>("AI.Error.InvalidRequest")({
_tag: Schema.tag("InvalidRequest"), _tag: Schema.tag("InvalidRequest"),
message: Schema.String, message: Schema.String,
parameter: Schema.optional(Schema.String), parameter: Schema.optional(Schema.String),
@@ -41,18 +41,18 @@ export class InvalidRequestReason extends Schema.Class<InvalidRequestReason>("LL
http: Schema.optional(HttpContext), http: Schema.optional(HttpContext),
}) {} }) {}
export class NoRouteReason extends Schema.Class<NoRouteReason>("LLM.Error.NoRoute")({ export class NoRouteReason extends Schema.Class<NoRouteReason>("AI.Error.NoRoute")({
_tag: Schema.tag("NoRoute"), _tag: Schema.tag("NoRoute"),
route: RouteID, route: RouteID,
provider: ProviderID, provider: ProviderID,
model: ModelID, model: ModelID,
}) { }) {
get message() { get message() {
return `No LLM route for ${this.provider}/${this.model} using ${this.route}` return `No AI route for ${this.provider}/${this.model} using ${this.route}`
} }
} }
export class AuthenticationReason extends Schema.Class<AuthenticationReason>("LLM.Error.Authentication")({ export class AuthenticationReason extends Schema.Class<AuthenticationReason>("AI.Error.Authentication")({
_tag: Schema.tag("Authentication"), _tag: Schema.tag("Authentication"),
message: Schema.String, message: Schema.String,
kind: Schema.Literals(["missing", "invalid", "expired", "insufficient-permissions", "unknown"]), kind: Schema.Literals(["missing", "invalid", "expired", "insufficient-permissions", "unknown"]),
@@ -60,7 +60,7 @@ export class AuthenticationReason extends Schema.Class<AuthenticationReason>("LL
http: Schema.optional(HttpContext), http: Schema.optional(HttpContext),
}) {} }) {}
export class RateLimitReason extends Schema.Class<RateLimitReason>("LLM.Error.RateLimit")({ export class RateLimitReason extends Schema.Class<RateLimitReason>("AI.Error.RateLimit")({
_tag: Schema.tag("RateLimit"), _tag: Schema.tag("RateLimit"),
message: Schema.String, message: Schema.String,
retryAfterMs: Schema.optional(Schema.Number), retryAfterMs: Schema.optional(Schema.Number),
@@ -69,21 +69,21 @@ export class RateLimitReason extends Schema.Class<RateLimitReason>("LLM.Error.Ra
http: Schema.optional(HttpContext), http: Schema.optional(HttpContext),
}) {} }) {}
export class QuotaExceededReason extends Schema.Class<QuotaExceededReason>("LLM.Error.QuotaExceeded")({ export class QuotaExceededReason extends Schema.Class<QuotaExceededReason>("AI.Error.QuotaExceeded")({
_tag: Schema.tag("QuotaExceeded"), _tag: Schema.tag("QuotaExceeded"),
message: Schema.String, message: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata), providerMetadata: Schema.optional(ProviderMetadata),
http: Schema.optional(HttpContext), http: Schema.optional(HttpContext),
}) {} }) {}
export class ContentPolicyReason extends Schema.Class<ContentPolicyReason>("LLM.Error.ContentPolicy")({ export class ContentPolicyReason extends Schema.Class<ContentPolicyReason>("AI.Error.ContentPolicy")({
_tag: Schema.tag("ContentPolicy"), _tag: Schema.tag("ContentPolicy"),
message: Schema.String, message: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata), providerMetadata: Schema.optional(ProviderMetadata),
http: Schema.optional(HttpContext), http: Schema.optional(HttpContext),
}) {} }) {}
export class ProviderInternalReason extends Schema.Class<ProviderInternalReason>("LLM.Error.ProviderInternal")({ export class ProviderInternalReason extends Schema.Class<ProviderInternalReason>("AI.Error.ProviderInternal")({
_tag: Schema.tag("ProviderInternal"), _tag: Schema.tag("ProviderInternal"),
message: Schema.String, message: Schema.String,
status: Schema.optional(Schema.Number), status: Schema.optional(Schema.Number),
@@ -92,7 +92,7 @@ export class ProviderInternalReason extends Schema.Class<ProviderInternalReason>
http: Schema.optional(HttpContext), http: Schema.optional(HttpContext),
}) {} }) {}
export class TransportReason extends Schema.Class<TransportReason>("LLM.Error.Transport")({ export class TransportReason extends Schema.Class<TransportReason>("AI.Error.Transport")({
_tag: Schema.tag("Transport"), _tag: Schema.tag("Transport"),
message: Schema.String, message: Schema.String,
kind: Schema.optional(Schema.String), kind: Schema.optional(Schema.String),
@@ -101,7 +101,7 @@ export class TransportReason extends Schema.Class<TransportReason>("LLM.Error.Tr
}) {} }) {}
export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOutputReason>( export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOutputReason>(
"LLM.Error.InvalidProviderOutput", "AI.Error.InvalidProviderOutput",
)({ )({
_tag: Schema.tag("InvalidProviderOutput"), _tag: Schema.tag("InvalidProviderOutput"),
message: Schema.String, message: Schema.String,
@@ -110,7 +110,7 @@ export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOut
providerMetadata: Schema.optional(ProviderMetadata), providerMetadata: Schema.optional(ProviderMetadata),
}) {} }) {}
export class UnknownProviderReason extends Schema.Class<UnknownProviderReason>("LLM.Error.UnknownProvider")({ export class UnknownProviderReason extends Schema.Class<UnknownProviderReason>("AI.Error.UnknownProvider")({
_tag: Schema.tag("UnknownProvider"), _tag: Schema.tag("UnknownProvider"),
message: Schema.String, message: Schema.String,
status: Schema.optional(Schema.Number), status: Schema.optional(Schema.Number),
@@ -118,7 +118,7 @@ export class UnknownProviderReason extends Schema.Class<UnknownProviderReason>("
http: Schema.optional(HttpContext), http: Schema.optional(HttpContext),
}) {} }) {}
export const LLMErrorReason = Schema.Union([ export const AIErrorReason = Schema.Union([
InvalidRequestReason, InvalidRequestReason,
NoRouteReason, NoRouteReason,
AuthenticationReason, AuthenticationReason,
@@ -130,12 +130,12 @@ export const LLMErrorReason = Schema.Union([
InvalidProviderOutputReason, InvalidProviderOutputReason,
UnknownProviderReason, UnknownProviderReason,
]).pipe(Schema.toTaggedUnion("_tag")) ]).pipe(Schema.toTaggedUnion("_tag"))
export type LLMErrorReason = Schema.Schema.Type<typeof LLMErrorReason> export type AIErrorReason = Schema.Schema.Type<typeof AIErrorReason>
export class LLMError extends Schema.TaggedErrorClass<LLMError>()("LLM.Error", { export class AIError extends Schema.TaggedErrorClass<AIError>()("AI.Error", {
module: Schema.String, module: Schema.String,
method: Schema.String, method: Schema.String,
reason: LLMErrorReason, reason: AIErrorReason,
}) { }) {
override readonly cause = this.reason override readonly cause = this.reason
+2 -26
View File
@@ -1,6 +1,5 @@
import { Schema } from "effect" import { Schema } from "effect"
import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, RouteID, ToolCallID } from "./ids" import { ContentBlockID, FinishReason, ProviderMetadata, ToolCallID } from "./ids"
import { ModelSchema } from "./options"
import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages" import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages"
import { ProviderFailureClassification } from "./errors" import { ProviderFailureClassification } from "./errors"
@@ -49,7 +48,7 @@ import { ProviderFailureClassification } from "./errors"
* for fields we don't normalize and for billing-level audit trails. * for fields we don't normalize and for billing-level audit trails.
* Matches the same escape-hatch field on `LLMEvent`. * Matches the same escape-hatch field on `LLMEvent`.
*/ */
export class Usage extends Schema.Class<Usage>("LLM.Usage")({ export class Usage extends Schema.Class<Usage>("AI.Usage")({
inputTokens: Schema.optional(Schema.Number), inputTokens: Schema.optional(Schema.Number),
outputTokens: Schema.optional(Schema.Number), outputTokens: Schema.optional(Schema.Number),
nonCachedInputTokens: Schema.optional(Schema.Number), nonCachedInputTokens: Schema.optional(Schema.Number),
@@ -314,29 +313,6 @@ export const LLMEvent = Object.assign(llmEventTagged, {
}) })
export type LLMEvent = Schema.Schema.Type<typeof llmEventTagged> export type LLMEvent = Schema.Schema.Type<typeof llmEventTagged>
export class PreparedRequest extends Schema.Class<PreparedRequest>("LLM.PreparedRequest")({
id: Schema.String,
route: RouteID,
protocol: ProtocolID,
model: ModelSchema,
body: Schema.Unknown,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}) {}
/**
* A `PreparedRequest` whose `body` is typed as `Body`. Use with the generic
* on `LLMClient.prepare<Body>(...)` when the caller knows which route their
* request will resolve to and wants its native shape statically exposed
* (debug UIs, request previews, plan rendering).
*
* The runtime body is identical the route still emits `body: unknown` so
* this is a type-level assertion the caller makes about what they expect to
* find. The prepare runtime does not validate the assertion.
*/
export type PreparedRequestOf<Body> = Omit<PreparedRequest, "body"> & {
readonly body: Body
}
const responseText = (events: ReadonlyArray<LLMEvent>) => const responseText = (events: ReadonlyArray<LLMEvent>) =>
events events
.filter(LLMEvent.is.textDelta) .filter(LLMEvent.is.textDelta)
+4 -3
View File
@@ -1,5 +1,6 @@
import { Schema } from "effect" import { Schema } from "effect"
import { LLM, ProviderMetadata } from "@opencode-ai/schema/llm" import { ProviderMetadata } from "@opencode-ai/schema/ai"
import { LLM } from "@opencode-ai/schema/llm"
export { ProviderMetadata } export { ProviderMetadata }
@@ -11,10 +12,10 @@ export type ProtocolID = Schema.Schema.Type<typeof ProtocolID>
export const RouteID = Schema.String export const RouteID = Schema.String
export type RouteID = Schema.Schema.Type<typeof RouteID> export type RouteID = Schema.Schema.Type<typeof RouteID>
export const ModelID = Schema.String.pipe(Schema.brand("LLM.ModelID")) export const ModelID = Schema.String.pipe(Schema.brand("AI.ModelID"))
export type ModelID = typeof ModelID.Type export type ModelID = typeof ModelID.Type
export const ProviderID = Schema.String.pipe(Schema.brand("LLM.ProviderID")) export const ProviderID = Schema.String.pipe(Schema.brand("AI.ProviderID"))
export type ProviderID = typeof ProviderID.Type export type ProviderID = typeof ProviderID.Type
export const ResponseID = Schema.String export const ResponseID = Schema.String
+4 -2
View File
@@ -1,7 +1,7 @@
import { Schema } from "effect" import { Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool" import { Tool } from "@opencode-ai/schema/tool"
import { JsonSchema, MessageRole, ProviderMetadata } from "./ids" import { JsonSchema, MessageRole, ProviderMetadata } from "./ids"
import { CacheHint, CachePolicy, GenerationOptions, HttpOptions, ModelSchema, ProviderOptions } from "./options" import { CacheHint, CachePolicy, GenerationOptions, HttpOptions, LanguageModelSchema, ProviderOptions } from "./options"
import { isRecord } from "../utils/record" import { isRecord } from "../utils/record"
const systemPartSchema = Schema.Struct({ const systemPartSchema = Schema.Struct({
@@ -124,6 +124,7 @@ export const ToolCallPart = Object.assign(
name: Schema.String, name: Schema.String,
input: Schema.Unknown, input: Schema.Unknown,
providerExecuted: Schema.optional(Schema.Boolean), providerExecuted: Schema.optional(Schema.Boolean),
cache: Schema.optional(CacheHint),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
providerMetadata: Schema.optional(ProviderMetadata), providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Content.ToolCall" }), }).annotate({ identifier: "LLM.Content.ToolCall" }),
@@ -168,6 +169,7 @@ export const ReasoningPart = Schema.Struct({
type: Schema.Literal("reasoning"), type: Schema.Literal("reasoning"),
text: Schema.String, text: Schema.String,
encrypted: Schema.optional(Schema.String), encrypted: Schema.optional(Schema.String),
cache: Schema.optional(CacheHint),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
providerMetadata: Schema.optional(ProviderMetadata), providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Content.Reasoning" }) }).annotate({ identifier: "LLM.Content.Reasoning" })
@@ -261,7 +263,7 @@ export namespace ToolChoice {
export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({ export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({
id: Schema.optional(Schema.String), id: Schema.optional(Schema.String),
model: ModelSchema, model: LanguageModelSchema,
system: Schema.Array(SystemPart), system: Schema.Array(SystemPart),
messages: Schema.Array(Message), messages: Schema.Array(Message),
tools: Schema.Array(ToolDefinition), tools: Schema.Array(ToolDefinition),
+57 -40
View File
@@ -50,7 +50,7 @@ export const mergeProviderOptions = (
return Object.keys(result).length === 0 ? undefined : result return Object.keys(result).length === 0 ? undefined : result
} }
export class HttpOptions extends Schema.Class<HttpOptions>("LLM.HttpOptions")({ export class HttpOptions extends Schema.Class<HttpOptions>("AI.HttpOptions")({
body: Schema.optional(JsonSchema), body: Schema.optional(JsonSchema),
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
query: Schema.optional(Schema.Record(Schema.String, Schema.String)), query: Schema.optional(Schema.Record(Schema.String, Schema.String)),
@@ -121,31 +121,32 @@ export const mergeGenerationOptions = (...items: ReadonlyArray<GenerationOptions
return Object.values(result).some((value) => value !== undefined) ? result : undefined return Object.values(result).some((value) => value !== undefined) ? result : undefined
} }
export class ModelLimits extends Schema.Class<ModelLimits>("LLM.ModelLimits")({ export class LanguageModelLimits extends Schema.Class<LanguageModelLimits>("LLM.LanguageModelLimits")({
context: Schema.optional(Schema.Number), context: Schema.optional(Schema.Number),
input: Schema.optional(Schema.Number),
output: Schema.optional(Schema.Number), output: Schema.optional(Schema.Number),
}) {} }) {}
export namespace ModelLimits { export namespace LanguageModelLimits {
export type Input = ModelLimits | ConstructorParameters<typeof ModelLimits>[0] export type Input = LanguageModelLimits | ConstructorParameters<typeof LanguageModelLimits>[0]
/** Normalize model limit input into the canonical `ModelLimits` class. */ /** Normalize model limit input into the canonical `LanguageModelLimits` class. */
export const make = (input: Input | undefined) => export const make = (input: Input | undefined) =>
input instanceof ModelLimits ? input : new ModelLimits(input ?? {}) input instanceof LanguageModelLimits ? input : new LanguageModelLimits(input ?? {})
} }
export class ModelDefaults extends Schema.Class<ModelDefaults>("LLM.ModelDefaults")({ export class LanguageModelDefaults extends Schema.Class<LanguageModelDefaults>("LLM.LanguageModelDefaults")({
limits: Schema.optional(ModelLimits), limits: Schema.optional(LanguageModelLimits),
generation: Schema.optional(GenerationOptions), generation: Schema.optional(GenerationOptions),
providerOptions: Schema.optional(ProviderOptions), providerOptions: Schema.optional(ProviderOptions),
http: Schema.optional(HttpOptions), http: Schema.optional(HttpOptions),
}) {} }) {}
export namespace ModelDefaults { export namespace LanguageModelDefaults {
export type Input = export type Input =
| ModelDefaults | LanguageModelDefaults
| { | {
readonly limits?: ModelLimits.Input readonly limits?: LanguageModelLimits.Input
readonly generation?: GenerationOptions.Input readonly generation?: GenerationOptions.Input
readonly providerOptions?: ProviderOptions readonly providerOptions?: ProviderOptions
readonly http?: HttpOptions.Input readonly http?: HttpOptions.Input
@@ -153,9 +154,9 @@ export namespace ModelDefaults {
/** Normalize selected-model request defaults without applying precedence. */ /** Normalize selected-model request defaults without applying precedence. */
export const make = (input: Input) => { export const make = (input: Input) => {
if (input instanceof ModelDefaults) return input if (input instanceof LanguageModelDefaults) return input
return new ModelDefaults({ return new LanguageModelDefaults({
limits: input.limits === undefined ? undefined : ModelLimits.make(input.limits), limits: input.limits === undefined ? undefined : LanguageModelLimits.make(input.limits),
generation: input.generation === undefined ? undefined : GenerationOptions.make(input.generation), generation: input.generation === undefined ? undefined : GenerationOptions.make(input.generation),
providerOptions: input.providerOptions, providerOptions: input.providerOptions,
http: input.http === undefined ? undefined : HttpOptions.make(input.http), http: input.http === undefined ? undefined : HttpOptions.make(input.http),
@@ -163,29 +164,39 @@ export namespace ModelDefaults {
} }
} }
export const ModelToolSchemaCompatibility = Schema.Literals(["gemini", "moonshot"]) export const LanguageModelToolSchemaCompatibility = Schema.Literals(["gemini", "moonshot"])
export type ModelToolSchemaCompatibility = Schema.Schema.Type<typeof ModelToolSchemaCompatibility> export type LanguageModelToolSchemaCompatibility = Schema.Schema.Type<typeof LanguageModelToolSchemaCompatibility>
export class ModelCompatibility extends Schema.Class<ModelCompatibility>("LLM.ModelCompatibility")({ export const LanguageModelMaxTokensFieldCompatibility = Schema.Literals(["max_completion_tokens", "max_tokens"])
toolSchema: Schema.optional(ModelToolSchemaCompatibility), export type LanguageModelMaxTokensFieldCompatibility = Schema.Schema.Type<
typeof LanguageModelMaxTokensFieldCompatibility
>
export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompatibility>(
"LLM.LanguageModelCompatibility",
)({
toolSchema: Schema.optional(LanguageModelToolSchemaCompatibility),
reasoningField: Schema.optional(Schema.String), reasoningField: Schema.optional(Schema.String),
maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility),
}) {} }) {}
export namespace ModelCompatibility { export namespace LanguageModelCompatibility {
export type Input = ModelCompatibility | ConstructorParameters<typeof ModelCompatibility>[0] export type Input = LanguageModelCompatibility | ConstructorParameters<typeof LanguageModelCompatibility>[0]
/** Normalize model/upstream compatibility metadata without projecting requests. */ /** Normalize model/upstream compatibility metadata without projecting requests. */
export const make = (input: Input) => (input instanceof ModelCompatibility ? input : new ModelCompatibility(input)) export const make = (input: Input) =>
input instanceof LanguageModelCompatibility ? input : new LanguageModelCompatibility(input)
} }
export class Model { export class LanguageModel<Options extends ProviderOptions = ProviderOptions> {
declare protected readonly _ProviderOptions: Options
readonly id: ModelID readonly id: ModelID
readonly provider: ProviderID readonly provider: ProviderID
readonly route: AnyRoute readonly route: AnyRoute
readonly defaults?: ModelDefaults readonly defaults?: LanguageModelDefaults
readonly compatibility?: ModelCompatibility readonly compatibility?: LanguageModelCompatibility
constructor(input: Model.ConstructorInput) { constructor(input: LanguageModel.ConstructorInput) {
this.id = input.id this.id = input.id
this.provider = input.provider this.provider = input.provider
this.route = input.route this.route = input.route
@@ -193,17 +204,18 @@ export class Model {
this.compatibility = input.compatibility this.compatibility = input.compatibility
} }
static make(input: Model.Input) { static make<Options extends ProviderOptions = ProviderOptions>(input: LanguageModel.Input) {
return new Model({ return new LanguageModel<Options>({
id: ModelID.make(input.id), id: ModelID.make(input.id),
provider: ProviderID.make(input.provider), provider: ProviderID.make(input.provider),
route: input.route, route: input.route,
defaults: input.defaults === undefined ? undefined : ModelDefaults.make(input.defaults), defaults: input.defaults === undefined ? undefined : LanguageModelDefaults.make(input.defaults),
compatibility: input.compatibility === undefined ? undefined : ModelCompatibility.make(input.compatibility), compatibility:
input.compatibility === undefined ? undefined : LanguageModelCompatibility.make(input.compatibility),
}) })
} }
static input(model: Model): Model.ConstructorInput { static input<Options extends ProviderOptions>(model: LanguageModel<Options>): LanguageModel.ConstructorInput {
return { return {
id: model.id, id: model.id,
provider: model.provider, provider: model.provider,
@@ -213,35 +225,40 @@ export class Model {
} }
} }
static update(model: Model, patch: Partial<Model.Input>) { static update<Options extends ProviderOptions>(model: LanguageModel<Options>, patch: Partial<LanguageModel.Input>) {
if (Object.keys(patch).length === 0) return model if (Object.keys(patch).length === 0) return model
return Model.make({ return LanguageModel.make<Options>({
...Model.input(model), ...LanguageModel.input(model),
...patch, ...patch,
}) })
} }
} }
export namespace Model { export namespace LanguageModel {
export type ConstructorInput = { export type ConstructorInput = {
readonly id: ModelID readonly id: ModelID
readonly provider: ProviderID readonly provider: ProviderID
readonly route: AnyRoute readonly route: AnyRoute
readonly defaults?: ModelDefaults readonly defaults?: LanguageModelDefaults
readonly compatibility?: ModelCompatibility readonly compatibility?: LanguageModelCompatibility
} }
export type Input = Omit<ConstructorInput, "id" | "provider" | "defaults" | "compatibility"> & { export type Input = Omit<ConstructorInput, "id" | "provider" | "defaults" | "compatibility"> & {
readonly id: string | ModelID readonly id: string | ModelID
readonly provider: string | ProviderID readonly provider: string | ProviderID
readonly defaults?: ModelDefaults.Input readonly defaults?: LanguageModelDefaults.Input
readonly compatibility?: ModelCompatibility.Input readonly compatibility?: LanguageModelCompatibility.Input
} }
} }
export type ModelInput = Model.Input export type LanguageModelInput = LanguageModel.Input
export const ModelSchema = Schema.declare((value): value is Model => value instanceof Model, { expected: "LLM.Model" }) export type LanguageModelProviderOptions<SelectedModel> =
SelectedModel extends LanguageModel<infer Options> ? Options : never
export const LanguageModelSchema = Schema.declare((value): value is LanguageModel => value instanceof LanguageModel, {
expected: "LLM.LanguageModel",
})
export class CacheHint extends Schema.Class<CacheHint>("LLM.CacheHint")({ export class CacheHint extends Schema.Class<CacheHint>("LLM.CacheHint")({
type: Schema.Literals(["ephemeral", "persistent"]), type: Schema.Literals(["ephemeral", "persistent"]),
+3 -4
View File
@@ -5,13 +5,13 @@ import {
LLMEvent, LLMEvent,
LLMResponse, LLMResponse,
type FinishReasonDetails, type FinishReasonDetails,
type LLMError, type AIError,
type LLMRequest, type LLMRequest,
type UsageInput, type UsageInput,
} from "./schema" } from "./schema"
import { Context, Deferred, Effect, Latch, Layer, Queue, Scope, Stream } from "effect" import { Context, Deferred, Effect, Latch, Layer, Queue, Scope, Stream } from "effect"
export type Response = readonly LLMEvent[] | Stream.Stream<LLMEvent, LLMError> export type Response = readonly LLMEvent[] | Stream.Stream<LLMEvent, AIError>
export type Gate = Readonly<{ started: Effect.Effect<void>; release: Effect.Effect<void> }> export type Gate = Readonly<{ started: Effect.Effect<void>; release: Effect.Effect<void> }>
@@ -63,7 +63,7 @@ export const textWithUsage = (value: string, id: string, inputTokens: number) =>
export const tool = (id: string, name: string, input: unknown) => toolCalls(LLMEvent.toolCall({ id, name, input })) export const tool = (id: string, name: string, input: unknown) => toolCalls(LLMEvent.toolCall({ id, name, input }))
export const failAfter = (error: LLMError, ...events: readonly LLMEvent[]) => export const failAfter = (error: AIError, ...events: readonly LLMEvent[]) =>
Stream.fromIterable(events).pipe(Stream.concat(Stream.fail(error))) Stream.fromIterable(events).pipe(Stream.concat(Stream.fail(error)))
export const hangAfter = (...events: readonly LLMEvent[]) => Stream.concat(Stream.fromIterable(events), Stream.never) export const hangAfter = (...events: readonly LLMEvent[]) => Stream.concat(Stream.fromIterable(events), Stream.never)
@@ -99,7 +99,6 @@ export const layer = (options: LayerOptions = {}) =>
) )
}) as LLMClientShape["stream"] }) as LLMClientShape["stream"]
const client = LLMClient.Service.of({ const client = LLMClient.Service.of({
prepare: () => Effect.die("TestLLM does not prepare provider-native requests"),
stream, stream,
generate: (request) => generate: (request) =>
stream(request).pipe( stream(request).pipe(
+6 -6
View File
@@ -2,11 +2,12 @@ import { describe, expect } from "bun:test"
import { Effect, Schema, Stream } from "effect" import { Effect, Schema, Stream } from "effect"
import { LLM, LLMRequest, LLMResponse } from "../src" import { LLM, LLMRequest, LLMResponse } from "../src"
import { Route, Endpoint, LLMClient, Protocol, type FramingDef } from "../src/route" import { Route, Endpoint, LLMClient, Protocol, type FramingDef } from "../src/route"
import { Model } from "../src/schema" import { compileRequest } from "../src/route/client"
import { LanguageModel } from "../src/schema"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
import { dynamicResponse } from "./lib/http" import { dynamicResponse } from "./lib/http"
const updateModel = (model: Model, patch: Partial<Model.Input>) => Model.update(model, patch) const updateModel = (model: LanguageModel, patch: Partial<LanguageModel.Input>) => LanguageModel.update(model, patch)
const Json = Schema.fromJsonString(Schema.Unknown) const Json = Schema.fromJsonString(Schema.Unknown)
const encodeJson = Schema.encodeSync(Json) const encodeJson = Schema.encodeSync(Json)
@@ -85,7 +86,7 @@ const configuredGemini = gemini.with({ endpoint: { baseURL: "https://fake.local"
const request = LLM.request({ const request = LLM.request({
id: "req_1", id: "req_1",
model: Model.make({ model: LanguageModel.make({
id: "fake-model", id: "fake-model",
provider: "fake-provider", provider: "fake-provider",
route: configuredFake, route: configuredFake,
@@ -139,8 +140,7 @@ describe("llm route", () => {
it.effect("selects routes by model route value", () => it.effect("selects routes by model route value", () =>
Effect.gen(function* () { Effect.gen(function* () {
const llm = yield* LLMClient.Service const prepared = yield* compileRequest(
const prepared = yield* llm.prepare(
LLMRequest.update(request, { model: updateModel(request.model, { route: configuredGemini }) }), LLMRequest.update(request, { model: updateModel(request.model, { route: configuredGemini }) }),
) )
@@ -173,7 +173,7 @@ describe("llm route", () => {
framing: fakeFraming, framing: fakeFraming,
}) })
const prepared = yield* (yield* LLMClient.Service).prepare( const prepared = yield* compileRequest(
LLMRequest.update(request, { model: updateModel(request.model, { route: duplicate }) }), LLMRequest.update(request, { model: updateModel(request.model, { route: duplicate }) }),
) )
+4 -4
View File
@@ -1,6 +1,6 @@
import { Config } from "effect" import { Config } from "effect"
import { Auth } from "../src/route" import { Auth } from "../src/route"
import type { ModelFactory } from "../src/route/auth-options" import type { LanguageModelFactory } from "../src/route/auth-options"
import * as OpenAIChat from "../src/protocols/openai-chat" import * as OpenAIChat from "../src/protocols/openai-chat"
import * as AmazonBedrock from "../src/providers/amazon-bedrock" import * as AmazonBedrock from "../src/providers/amazon-bedrock"
import * as Anthropic from "../src/providers/anthropic" import * as Anthropic from "../src/providers/anthropic"
@@ -23,13 +23,13 @@ type BaseOptions = {
readonly headers?: Record<string, string> readonly headers?: Record<string, string>
} }
type Model = { type LanguageModel = {
readonly id: string readonly id: string
} }
declare const auth: Auth.Definition declare const auth: Auth.Definition
declare const optionalAuthModel: ModelFactory<BaseOptions, "optional", Model> declare const optionalAuthModel: LanguageModelFactory<BaseOptions, "optional", LanguageModel>
declare const requiredAuthModel: ModelFactory<BaseOptions, "required", Model> declare const requiredAuthModel: LanguageModelFactory<BaseOptions, "required", LanguageModel>
const configApiKey = Config.redacted("OPENAI_API_KEY") const configApiKey = Config.redacted("OPENAI_API_KEY")
OpenAIChat.route.model({ id: "gpt-4.1-mini" }) OpenAIChat.route.model({ id: "gpt-4.1-mini" })
+2 -2
View File
@@ -4,12 +4,12 @@ import { Headers } from "effect/unstable/http"
import { LLM } from "../src" import { LLM } from "../src"
import { Auth } from "../src/route/auth" import { Auth } from "../src/route/auth"
import * as OpenAIChat from "../src/protocols/openai-chat" import * as OpenAIChat from "../src/protocols/openai-chat"
import { Model } from "../src/schema" import { LanguageModel } from "../src/schema"
import { it } from "./lib/effect" import { it } from "./lib/effect"
const request = LLM.request({ const request = LLM.request({
id: "req_auth", id: "req_auth",
model: Model.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route }), model: LanguageModel.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route }),
prompt: "hello", prompt: "hello",
}) })
+14 -13
View File
@@ -1,7 +1,8 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { CacheHint, LLM, Message } from "../src" import { CacheHint, LLM, Message } from "../src"
import { Auth, LLMClient } from "../src/route" import { Auth } from "../src/route"
import { compileRequest } from "../src/route/client"
import { AmazonBedrock } from "../src/providers" import { AmazonBedrock } from "../src/providers"
import * as AnthropicMessages from "../src/protocols/anthropic-messages" import * as AnthropicMessages from "../src/protocols/anthropic-messages"
import * as Gemini from "../src/protocols/gemini" import * as Gemini from "../src/protocols/gemini"
@@ -31,7 +32,7 @@ const geminiModel = Gemini.route
describe("applyCachePolicy", () => { describe("applyCachePolicy", () => {
it.effect("undefined cache resolves to 'auto' (the recommended default)", () => it.effect("undefined cache resolves to 'auto' (the recommended default)", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* LLMClient.prepare( const prepared = yield* compileRequest(
LLM.request({ LLM.request({
model: anthropicModel, model: anthropicModel,
system: "You are concise.", system: "You are concise.",
@@ -50,7 +51,7 @@ describe("applyCachePolicy", () => {
it.effect("'auto' marks the last tool, first and last system parts, and final message boundary on Anthropic", () => it.effect("'auto' marks the last tool, first and last system parts, and final message boundary on Anthropic", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* LLMClient.prepare( const prepared = yield* compileRequest(
LLM.request({ LLM.request({
model: anthropicModel, model: anthropicModel,
system: [ system: [
@@ -87,7 +88,7 @@ describe("applyCachePolicy", () => {
it.effect("'auto' is a no-op on OpenAI (implicit caching protocol)", () => it.effect("'auto' is a no-op on OpenAI (implicit caching protocol)", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* LLMClient.prepare( const prepared = yield* compileRequest(
LLM.request({ LLM.request({
model: openaiModel, model: openaiModel,
system: "Sys", system: "Sys",
@@ -106,7 +107,7 @@ describe("applyCachePolicy", () => {
it.effect("'auto' is a no-op on Gemini (out-of-band caching protocol)", () => it.effect("'auto' is a no-op on Gemini (out-of-band caching protocol)", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* LLMClient.prepare( const prepared = yield* compileRequest(
LLM.request({ LLM.request({
model: geminiModel, model: geminiModel,
system: "Sys", system: "Sys",
@@ -123,7 +124,7 @@ describe("applyCachePolicy", () => {
it.effect("'auto' on Bedrock emits cachePoint markers in the right places", () => it.effect("'auto' on Bedrock emits cachePoint markers in the right places", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* LLMClient.prepare( const prepared = yield* compileRequest(
LLM.request({ LLM.request({
model: bedrockModel, model: bedrockModel,
system: [ system: [
@@ -157,7 +158,7 @@ describe("applyCachePolicy", () => {
it.effect("'none' disables auto placement even when manual hints exist", () => it.effect("'none' disables auto placement even when manual hints exist", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* LLMClient.prepare( const prepared = yield* compileRequest(
LLM.request({ LLM.request({
model: anthropicModel, model: anthropicModel,
system: "Sys", system: "Sys",
@@ -176,7 +177,7 @@ describe("applyCachePolicy", () => {
it.effect("granular object form: tools-only marks just tools", () => it.effect("granular object form: tools-only marks just tools", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* LLMClient.prepare( const prepared = yield* compileRequest(
LLM.request({ LLM.request({
model: anthropicModel, model: anthropicModel,
system: "Sys", system: "Sys",
@@ -195,7 +196,7 @@ describe("applyCachePolicy", () => {
it.effect("auto policy preserves manual CacheHints on other parts", () => it.effect("auto policy preserves manual CacheHints on other parts", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* LLMClient.prepare( const prepared = yield* compileRequest(
LLM.request({ LLM.request({
model: anthropicModel, model: anthropicModel,
system: [ system: [
@@ -241,7 +242,7 @@ describe("applyCachePolicy", () => {
expect("cache" in tail ? tail.cache : undefined).toBeUndefined() expect("cache" in tail ? tail.cache : undefined).toBeUndefined()
expect(applyCachePolicy(applied)).toBe(applied) expect(applyCachePolicy(applied)).toBe(applied)
const prepared = yield* LLMClient.prepare(request) const prepared = yield* compileRequest(request)
const body = prepared.body as { const body = prepared.body as {
tools: Array<{ cache_control?: unknown }> tools: Array<{ cache_control?: unknown }>
@@ -261,7 +262,7 @@ describe("applyCachePolicy", () => {
it.effect("ttlSeconds in the policy flows through to wire markers", () => it.effect("ttlSeconds in the policy flows through to wire markers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* LLMClient.prepare( const prepared = yield* compileRequest(
LLM.request({ LLM.request({
model: anthropicModel, model: anthropicModel,
system: "Sys", system: "Sys",
@@ -278,7 +279,7 @@ describe("applyCachePolicy", () => {
it.effect("messages: { tail: 2 } marks the last 2 message boundaries", () => it.effect("messages: { tail: 2 } marks the last 2 message boundaries", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* LLMClient.prepare( const prepared = yield* compileRequest(
LLM.request({ LLM.request({
model: anthropicModel, model: anthropicModel,
messages: [Message.user("u1"), Message.assistant("a1"), Message.user("u2"), Message.assistant("a2")], messages: [Message.user("u1"), Message.assistant("a1"), Message.user("u2"), Message.assistant("a2")],
@@ -296,7 +297,7 @@ describe("applyCachePolicy", () => {
it.effect("'latest-assistant' marks the last assistant message", () => it.effect("'latest-assistant' marks the last assistant message", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* LLMClient.prepare( const prepared = yield* compileRequest(
LLM.request({ LLM.request({
model: anthropicModel, model: anthropicModel,
messages: [Message.user("u1"), Message.assistant("a1"), Message.user("u2")], messages: [Message.user("u1"), Message.assistant("a1"), Message.user("u2")],
@@ -4,6 +4,7 @@ import { HttpClientRequest } from "effect/unstable/http"
import { LLM, mergeProviderOptions } from "../src" import { LLM, mergeProviderOptions } from "../src"
import { AnthropicMessages, OpenAIChat } from "../src/protocols" import { AnthropicMessages, OpenAIChat } from "../src/protocols"
import { Auth, LLMClient } from "../src/route" import { Auth, LLMClient } from "../src/route"
import { compileRequest } from "../src/route/client"
import { it } from "./lib/effect" import { it } from "./lib/effect"
import { dynamicResponse } from "./lib/http" import { dynamicResponse } from "./lib/http"
import { deltaChunk } from "./lib/openai-chunks" import { deltaChunk } from "./lib/openai-chunks"
@@ -44,7 +45,7 @@ describe("request option precedence", () => {
}) })
}) })
it.effect("prepares bodies with route defaults, model defaults, and call options in order", () => it.effect("compiles bodies with route defaults, model defaults, and call options in order", () =>
Effect.gen(function* () { Effect.gen(function* () {
const route = OpenAIChat.route.with({ const route = OpenAIChat.route.with({
endpoint: { baseURL: "https://api.openai.test/v1/" }, endpoint: { baseURL: "https://api.openai.test/v1/" },
@@ -59,7 +60,7 @@ describe("request option precedence", () => {
providerOptions: { openai: { reasoningEffort: "medium" } }, providerOptions: { openai: { reasoningEffort: "medium" } },
}, },
}) })
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>( const prepared = yield* compileRequest(
LLM.request({ LLM.request({
model, model,
prompt: "Say hello.", prompt: "Say hello.",
@@ -136,24 +137,61 @@ describe("request option precedence", () => {
), ),
) )
it.effect("rejects raw body overlays for protocol-owned roots", () => it.effect("transforms the final HTTP request after serialization and authentication", () =>
Effect.gen(function* () { LLMClient.generate(
const model = OpenAIChat.route LLM.request({
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) model: OpenAIChat.route
.model({ id: "gpt-4o-mini" }) .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("fresh-key") })
const error = yield* LLMClient.prepare( .model({ id: "gpt-4o-mini" }),
LLM.request({ prompt: "Say hello.",
model, }),
prompt: "Say hello.", {
http: { body: { model: "gpt-5", messages: [], tools: [] } }, transform: (request) =>
}), Effect.sync(() => {
).pipe(Effect.flip) expect(request.headers.authorization).toBe("Bearer fresh-key")
request.url = "https://proxy.test/v1/chat/completions"
request.headers["x-plugin"] = "transformed"
request.body = JSON.stringify({ transformed: true })
}),
},
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(web.url).toBe("https://proxy.test/v1/chat/completions")
expect(web.headers.get("x-plugin")).toBe("transformed")
expect(decodeJson(input.text)).toEqual({ transformed: true })
return input.respond(sseEvents(deltaChunk({}, "stop")), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
),
)
expect(error.reason).toMatchObject({ it.effect("applies raw body overlays after protocol lowering", () =>
_tag: "InvalidRequest", LLMClient.generate(
message: "http.body cannot overlay protocol-owned field(s): model, messages, tools", LLM.request({
}) model: OpenAIChat.route
}), .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4o-mini" }),
prompt: "Say hello.",
http: { body: { model: "gpt-5", messages: [], tools: [] } },
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
expect(decodeJson(input.text)).toMatchObject({ model: "gpt-5", messages: [], tools: [] })
return input.respond(sseEvents(deltaChunk({}, "stop")), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
),
) )
it.effect("uses model output limits after route limits and before call maxTokens", () => it.effect("uses model output limits after route limits and before call maxTokens", () =>
@@ -164,10 +202,8 @@ describe("request option precedence", () => {
limits: { output: 128 }, limits: { output: 128 },
}) })
const model = route.model({ id: "claude-sonnet-4-5", defaults: { limits: { output: 64 } } }) const model = route.model({ id: "claude-sonnet-4-5", defaults: { limits: { output: 64 } } })
const withoutMaxTokens = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>( const withoutMaxTokens = yield* compileRequest(LLM.request({ model, prompt: "Say hello.", cache: "none" }))
LLM.request({ model, prompt: "Say hello.", cache: "none" }), const withMaxTokens = yield* compileRequest(
)
const withMaxTokens = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({ model, prompt: "Say hello.", cache: "none", generation: { maxTokens: 32 } }), LLM.request({ model, prompt: "Say hello.", cache: "none", generation: { maxTokens: 32 } }),
) )
+10 -2
View File
@@ -1,4 +1,12 @@
import { LLM, Message, ToolCallPart, ToolDefinition, ToolResultPart, type ContentPart, type Model } from "../src" import {
LLM,
Message,
ToolCallPart,
ToolDefinition,
ToolResultPart,
type ContentPart,
type LanguageModel,
} from "../src"
export const basicContinuation = ["system", "user-text", "assistant-text", "user-follow-up"] as const export const basicContinuation = ["system", "user-text", "assistant-text", "user-follow-up"] as const
export const toolContinuation = ["tool-call", "tool-result"] as const export const toolContinuation = ["tool-call", "tool-result"] as const
@@ -40,7 +48,7 @@ export const continuationTool = ToolDefinition.make({
export function continuationRequest(input: { export function continuationRequest(input: {
readonly id: string readonly id: string
readonly model: Model readonly model: LanguageModel
readonly features: ReadonlyArray<ContinuationFeature> readonly features: ReadonlyArray<ContinuationFeature>
readonly image?: string readonly image?: string
}) { }) {
+2 -2
View File
@@ -2,11 +2,11 @@ import { describe, expect, test } from "bun:test"
import { LLM } from "../src" import { LLM } from "../src"
import * as OpenAIChat from "../src/protocols/openai-chat" import * as OpenAIChat from "../src/protocols/openai-chat"
import { Endpoint } from "../src/route" import { Endpoint } from "../src/route"
import { Model } from "../src/schema" import { LanguageModel } from "../src/schema"
const request = () => const request = () =>
LLM.request({ LLM.request({
model: Model.make({ model: LanguageModel.make({
id: "model-1", id: "model-1",
provider: "test", provider: "test",
route: OpenAIChat.route, route: OpenAIChat.route,
+26 -23
View File
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Layer, Ref } from "effect" import { Effect, Layer, Ref } from "effect"
import { Headers, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { Headers, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { LLM, LLMError } from "../src" import { LLM, AIError } from "../src"
import { LLMClient, RequestExecutor } from "../src/route" import { LLMClient, RequestExecutor } from "../src/route"
import * as OpenAIChat from "../src/protocols/openai-chat" import * as OpenAIChat from "../src/protocols/openai-chat"
import { dynamicResponse } from "./lib/http" import { dynamicResponse } from "./lib/http"
@@ -58,13 +58,13 @@ const countedResponsesLayer = (attempts: Ref.Ref<number>, responses: ReadonlyArr
), ),
) )
const expectLLMError = (error: unknown) => { const expectAIError = (error: unknown) => {
expect(error).toBeInstanceOf(LLMError) expect(error).toBeInstanceOf(AIError)
if (!(error instanceof LLMError)) throw new Error("expected LLMError") if (!(error instanceof AIError)) throw new Error("expected AIError")
return error return error
} }
const errorHttp = (error: LLMError) => ("http" in error.reason ? error.reason.http : undefined) const errorHttp = (error: AIError) => ("http" in error.reason ? error.reason.http : undefined)
describe("RequestExecutor", () => { describe("RequestExecutor", () => {
it.effect("classifies context overflow responses", () => it.effect("classifies context overflow responses", () =>
@@ -72,7 +72,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip) const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error) expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", classification: "context-overflow" }) expect(error.reason).toMatchObject({ _tag: "InvalidRequest", classification: "context-overflow" })
}).pipe( }).pipe(
Effect.provide( Effect.provide(
@@ -85,14 +85,17 @@ describe("RequestExecutor", () => {
), ),
) )
it.effect("does not classify generic HTTP 413 payload errors as context overflow", () => it.effect("classifies generic HTTP 413 payload errors", () =>
Effect.gen(function* () { Effect.gen(function* () {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip) const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error) expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) expect(error.reason).toMatchObject({
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined() _tag: "InvalidRequest",
classification: "payload-too-large",
http: { response: { status: 413 } },
})
}).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))), }).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))),
) )
@@ -101,7 +104,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip) const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error) expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined() expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
}).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))), }).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))),
@@ -114,7 +117,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip) const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error) expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "RateLimit" }) expect(error.reason).toMatchObject({ _tag: "RateLimit" })
}).pipe(Effect.provide(responsesLayer([new Response(body, { status: 400 })]))) }).pipe(Effect.provide(responsesLayer([new Response(body, { status: 400 })])))
@@ -131,7 +134,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip) const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error) expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" }) expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
}).pipe(Effect.provide(responsesLayer([new Response(body, { status: 400 })]))) }).pipe(Effect.provide(responsesLayer([new Response(body, { status: 400 })])))
@@ -145,7 +148,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip) const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error) expectAIError(error)
expect(error).toMatchObject({ expect(error).toMatchObject({
reason: { reason: {
_tag: "RateLimit", _tag: "RateLimit",
@@ -187,7 +190,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip) const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error) expectAIError(error)
expect(errorHttp(error)?.request.headers["x-safe"]).toBe("<redacted>") expect(errorHttp(error)?.request.headers["x-safe"]).toBe("<redacted>")
expect(errorHttp(error)?.response?.headers["x-safe"]).toBe("<redacted>") expect(errorHttp(error)?.response?.headers["x-safe"]).toBe("<redacted>")
}).pipe( }).pipe(
@@ -201,7 +204,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip) const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error) expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "RateLimit" }) expect(error.reason).toMatchObject({ _tag: "RateLimit" })
expect(error.reason._tag === "RateLimit" ? error.reason.rateLimit : undefined).toEqual({ expect(error.reason._tag === "RateLimit" ? error.reason.rateLimit : undefined).toEqual({
retryAfterMs: 0, retryAfterMs: 0,
@@ -234,7 +237,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip) const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error) expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" }) expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
expect(errorHttp(error)?.rateLimit).toEqual({ expect(errorHttp(error)?.rateLimit).toEqual({
retryAfterMs: 0, retryAfterMs: 0,
@@ -277,7 +280,7 @@ describe("RequestExecutor", () => {
), ),
) )
expectLLMError(error) expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status: 503 }) expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status: 503 })
expect(yield* Ref.get(attempts)).toBe(1) expect(yield* Ref.get(attempts)).toBe(1)
}), }),
@@ -290,7 +293,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip) const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error) expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status }) expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status })
}).pipe( }).pipe(
Effect.provide( Effect.provide(
@@ -313,7 +316,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip) const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error) expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "Authentication" }) expect(error.reason).toMatchObject({ _tag: "Authentication" })
expect(errorHttp(error)?.bodyTruncated).toBe(true) expect(errorHttp(error)?.bodyTruncated).toBe(true)
expect(errorHttp(error)?.body).toHaveLength(16_384) expect(errorHttp(error)?.body).toHaveLength(16_384)
@@ -332,7 +335,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip) const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error) expectAIError(error)
expect(errorHttp(error)?.body).toContain('"key":"<redacted>"') expect(errorHttp(error)?.body).toContain('"key":"<redacted>"')
expect(errorHttp(error)?.body).toContain("api_key=<redacted>") expect(errorHttp(error)?.body).toContain("api_key=<redacted>")
expect(errorHttp(error)?.body).not.toContain("body-secret") expect(errorHttp(error)?.body).not.toContain("body-secret")
@@ -353,7 +356,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(secretRequest).pipe(Effect.flip) const error = yield* executor.execute(secretRequest).pipe(Effect.flip)
expectLLMError(error) expectAIError(error)
expect(errorHttp(error)?.body).toContain("provider echoed <redacted>") expect(errorHttp(error)?.body).toContain("provider echoed <redacted>")
expect(errorHttp(error)?.body).toContain("authorization <redacted>") expect(errorHttp(error)?.body).toContain("authorization <redacted>")
expect(errorHttp(error)?.body).not.toContain("query-secret-123") expect(errorHttp(error)?.body).not.toContain("query-secret-123")
@@ -392,7 +395,7 @@ describe("RequestExecutor", () => {
Effect.flip, Effect.flip,
) )
expectLLMError(error) expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" }) expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
expect(yield* Ref.get(attempts)).toBe(1) expect(yield* Ref.get(attempts)).toBe(1)
}), }),
+3 -3
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { ImageInput, LLM, LLMClient, Provider } from "@opencode-ai/ai" import { AIError, ImageInput, LanguageModel, LLM, LLMClient, Provider } from "@opencode-ai/ai"
import { Route, Protocol } from "@opencode-ai/ai/route" import { Route, Protocol } from "@opencode-ai/ai/route"
import { Provider as ProviderSubpath } from "@opencode-ai/ai/provider" import { Provider as ProviderSubpath } from "@opencode-ai/ai/provider"
import { import {
@@ -26,6 +26,8 @@ describe("public exports", () => {
expect(LLM.request).toBeFunction() expect(LLM.request).toBeFunction()
expect(LLMClient.Service).toBeFunction() expect(LLMClient.Service).toBeFunction()
expect(LLMClient.layer).toBeDefined() expect(LLMClient.layer).toBeDefined()
expect(AIError).toBeFunction()
expect(LanguageModel.make).toBeFunction()
expect(ImageInput.bytes).toBeFunction() expect(ImageInput.bytes).toBeFunction()
expect(Provider.make).toBeFunction() expect(Provider.make).toBeFunction()
expect(ProviderSubpath.make).toBe(Provider.make) expect(ProviderSubpath.make).toBe(Provider.make)
@@ -53,9 +55,7 @@ describe("public exports", () => {
expect(CloudflareWorkersAI.configure).toBeFunction() expect(CloudflareWorkersAI.configure).toBeFunction()
expect(CloudflareWorkersAI.configure({ accountId: "fixture", apiKey: "fixture" }).model).toBeFunction() expect(CloudflareWorkersAI.configure({ accountId: "fixture", apiKey: "fixture" }).model).toBeFunction()
expect(OpenRouter.model).toBeFunction() expect(OpenRouter.model).toBeFunction()
expect(OpenRouter.provider.model).toBe(OpenRouter.model)
expect(XAI.model).toBeFunction() expect(XAI.model).toBeFunction()
expect(XAI.provider.model).toBe(XAI.model)
expect(XAI.provider.responses).toBe(XAI.responses) expect(XAI.provider.responses).toBe(XAI.responses)
expect(XAI.provider.chat).toBe(XAI.chat) expect(XAI.provider.chat).toBe(XAI.chat)
expect(XAI.configure({ apiKey: "fixture" }).responses("grok-4.3").route.id).toBe("openai-responses") expect(XAI.configure({ apiKey: "fixture" }).responses("grok-4.3").route.id).toBe("openai-responses")
File diff suppressed because one or more lines are too long
@@ -0,0 +1,47 @@
import { Schema } from "effect"
import { LLM, type LanguageModel, type LanguageModelProviderOptions, type ProviderOptions } from "../src"
import { OpenAIChat } from "../src/protocols"
interface ExampleOptions {
readonly [key: string]: unknown
readonly mode?: "fast" | "thorough"
}
type ExampleProviderOptions = ProviderOptions & {
readonly example?: ExampleOptions
}
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://example.com/v1" } })
.model<ExampleProviderOptions>({ id: "example" })
LLM.request({ model, prompt: "Hello", providerOptions: { example: { mode: "fast" } } })
LLM.request({ model, prompt: "Hello", providerOptions: { future: { option: true } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Known provider options preserve their value types.
providerOptions: { example: { mode: "slow" } },
})
LLM.generateObject({
model,
prompt: "Hello",
schema: Schema.Struct({ answer: Schema.String }),
providerOptions: { example: { mode: "thorough" } },
})
LLM.generateObject({
model,
prompt: "Hello",
jsonSchema: { type: "object" },
// @ts-expect-error Dynamic object generation uses the selected model's provider options.
providerOptions: { example: { mode: false } },
})
declare const generic: LanguageModel
LLM.request({ model: generic, prompt: "Hello", providerOptions: { arbitrary: { option: true } } })
const options: LanguageModelProviderOptions<typeof model> = { example: { mode: "fast" } }
void options
+13 -13
View File
@@ -6,7 +6,7 @@ import {
GenerationOptions, GenerationOptions,
LLMRequest, LLMRequest,
Message, Message,
Model, LanguageModel,
ToolCallPart, ToolCallPart,
ToolChoice, ToolChoice,
ToolDefinition, ToolDefinition,
@@ -20,13 +20,13 @@ describe("llm constructors", () => {
test("builds canonical schema classes from ergonomic input", () => { test("builds canonical schema classes from ergonomic input", () => {
const request = LLM.request({ const request = LLM.request({
id: "req_1", id: "req_1",
model: Model.make({ id: "fake-model", provider: "fake", route: chatRoute }), model: LanguageModel.make({ id: "fake-model", provider: "fake", route: chatRoute }),
system: "You are concise.", system: "You are concise.",
prompt: "Say hello.", prompt: "Say hello.",
}) })
expect(request).toBeInstanceOf(LLMRequest) expect(request).toBeInstanceOf(LLMRequest)
expect(request.model).toBeInstanceOf(Model) expect(request.model).toBeInstanceOf(LanguageModel)
expect(request.messages[0]).toBeInstanceOf(Message) expect(request.messages[0]).toBeInstanceOf(Message)
expect(request.system).toEqual([{ type: "text", text: "You are concise." }]) expect(request.system).toEqual([{ type: "text", text: "You are concise." }])
expect(request.messages[0]?.content).toEqual([{ type: "text", text: "Say hello." }]) expect(request.messages[0]?.content).toEqual([{ type: "text", text: "Say hello." }])
@@ -37,7 +37,7 @@ describe("llm constructors", () => {
test("updates requests without spreading schema class instances", () => { test("updates requests without spreading schema class instances", () => {
const base = LLM.request({ const base = LLM.request({
id: "req_1", id: "req_1",
model: Model.make({ id: "fake-model", provider: "fake", route: chatRoute }), model: LanguageModel.make({ id: "fake-model", provider: "fake", route: chatRoute }),
prompt: "Say hello.", prompt: "Say hello.",
}) })
const updated = LLMRequest.update(base, { const updated = LLMRequest.update(base, {
@@ -54,7 +54,7 @@ describe("llm constructors", () => {
test("keeps request options separate from route defaults", () => { test("keeps request options separate from route defaults", () => {
const request = LLM.request({ const request = LLM.request({
model: Model.make({ model: LanguageModel.make({
id: "fake-model", id: "fake-model",
provider: "fake", provider: "fake",
route: chatRoute.with({ route: chatRoute.with({
@@ -81,7 +81,7 @@ describe("llm constructors", () => {
test("updates canonical requests from the request datatype", () => { test("updates canonical requests from the request datatype", () => {
const base = LLM.request({ const base = LLM.request({
id: "req_1", id: "req_1",
model: Model.make({ id: "fake-model", provider: "fake", route: chatRoute }), model: LanguageModel.make({ id: "fake-model", provider: "fake", route: chatRoute }),
prompt: "Say hello.", prompt: "Say hello.",
}) })
const updated = LLMRequest.update(base, { messages: [...base.messages, Message.assistant("Hi.")] }) const updated = LLMRequest.update(base, { messages: [...base.messages, Message.assistant("Hi.")] })
@@ -94,19 +94,19 @@ describe("llm constructors", () => {
}) })
test("updates canonical models from the model datatype", () => { test("updates canonical models from the model datatype", () => {
const base = Model.make({ const base = LanguageModel.make({
id: "fake-model", id: "fake-model",
provider: "fake", provider: "fake",
route: chatRoute, route: chatRoute,
}) })
const updated = Model.update(base, { const updated = LanguageModel.update(base, {
route: responsesRoute, route: responsesRoute,
defaults: { generation: { maxTokens: 20 } }, defaults: { generation: { maxTokens: 20 } },
compatibility: { toolSchema: "gemini" }, compatibility: { toolSchema: "gemini" },
}) })
const updatedInput = Model.input(updated) const updatedInput = LanguageModel.input(updated)
expect(updated).toBeInstanceOf(Model) expect(updated).toBeInstanceOf(LanguageModel)
expect(String(updated.id)).toBe("fake-model") expect(String(updated.id)).toBe("fake-model")
expect(updated.route).toBe(responsesRoute) expect(updated.route).toBe(responsesRoute)
expect(updated.defaults?.generation).toEqual({ maxTokens: 20 }) expect(updated.defaults?.generation).toEqual({ maxTokens: 20 })
@@ -114,7 +114,7 @@ describe("llm constructors", () => {
expect(updatedInput.defaults).toBe(updated.defaults) expect(updatedInput.defaults).toBe(updated.defaults)
expect(updatedInput.compatibility).toBe(updated.compatibility) expect(updatedInput.compatibility).toBe(updated.compatibility)
expect(String(updatedInput.provider)).toBe("fake") expect(String(updatedInput.provider)).toBe("fake")
expect(Model.update(updated, {})).toBe(updated) expect(LanguageModel.update(updated, {})).toBe(updated)
}) })
test("carries model defaults and compatibility through route model selection", () => { test("carries model defaults and compatibility through route model selection", () => {
@@ -155,7 +155,7 @@ describe("llm constructors", () => {
expect(ToolChoice.make("required")).toEqual(new ToolChoice({ type: "required" })) expect(ToolChoice.make("required")).toEqual(new ToolChoice({ type: "required" }))
expect( expect(
LLM.request({ LLM.request({
model: Model.make({ model: LanguageModel.make({
id: "fake-model", id: "fake-model",
provider: "fake", provider: "fake",
route: chatRoute, route: chatRoute,
@@ -181,7 +181,7 @@ describe("llm constructors", () => {
{ type: "text", text: "Use parameterized SQL.", cache: new CacheHint({ type: "ephemeral" }) }, { type: "text", text: "Use parameterized SQL.", cache: new CacheHint({ type: "ephemeral" }) },
]) ])
const request = LLM.request({ const request = LLM.request({
model: Model.make({ id: "fake-model", provider: "fake", route: chatRoute }), model: LanguageModel.make({ id: "fake-model", provider: "fake", route: chatRoute }),
system: "Initial operator prompt.", system: "Initial operator prompt.",
messages: [Message.user("Review this."), update], messages: [Message.user("Review this."), update],
}) })
+22 -4
View File
@@ -6,7 +6,6 @@ describe("provider error classification", () => {
test("classifies provider token limit messages as context overflow", () => { test("classifies provider token limit messages as context overflow", () => {
const messages = [ const messages = [
"tokens in request more than max tokens allowed", "tokens in request more than max tokens allowed",
'{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}',
"Requested token count exceeds the model's maximum context length of 131072 tokens.", "Requested token count exceeds the model's maximum context length of 131072 tokens.",
"Input length (265330) exceeds model's maximum context length (262144).", "Input length (265330) exceeds model's maximum context length (262144).",
"Input length 131393 exceeds the maximum allowed input length of 131040 tokens.", "Input length 131393 exceeds the maximum allowed input length of 131040 tokens.",
@@ -19,6 +18,24 @@ describe("provider error classification", () => {
expect(messages.every(isContextOverflow)).toBe(true) expect(messages.every(isContextOverflow)).toBe(true)
}) })
test("classifies request size failures separately from context overflow", () => {
const failures = [
classifyProviderFailure({ message: "request too large", status: 413 }),
classifyProviderFailure({
message: '{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}',
status: 400,
}),
classifyProviderFailure({ message: "upstream request entity too large", status: 502 }),
]
expect(failures).toEqual(
failures.map((failure) =>
expect.objectContaining({ _tag: "InvalidRequest", classification: "payload-too-large" }),
),
)
expect(isContextOverflow("413 status code (no body)")).toBe(false)
})
test("does not classify rate limits as context overflow", () => { test("does not classify rate limits as context overflow", () => {
const messages = [ const messages = [
"Throttling error: Too many tokens, please wait before trying again.", "Throttling error: Too many tokens, please wait before trying again.",
@@ -59,9 +76,10 @@ describe("provider error classification", () => {
}) })
test("classifies transient client statuses as provider internal", () => { test("classifies transient client statuses as provider internal", () => {
expect( expect([408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag)).toEqual([
[408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag), "ProviderInternal",
).toEqual(["ProviderInternal", "ProviderInternal"]) "ProviderInternal",
])
}) })
test("classifies nested provider codes when a top-level code is also present", () => { test("classifies nested provider codes when a top-level code is also present", () => {
@@ -0,0 +1,13 @@
import { LLM } from "../../src"
import { AnthropicCompatible } from "../../src/providers"
const model = AnthropicCompatible.configure({ baseURL: "https://example.com" }).model("claude")
LLM.request({ model, prompt: "Hello", providerOptions: { anthropic: { effort: "high" } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Anthropic effort must be a string.
providerOptions: { anthropic: { effort: 1 } },
})
@@ -0,0 +1,13 @@
import { LLM } from "../../src"
import { Anthropic } from "../../src/providers"
const model = Anthropic.provider.model("claude-sonnet-4-5")
LLM.request({ model, prompt: "Hello", providerOptions: { anthropic: { thinking: { type: "adaptive" } } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Anthropic thinking modes are a fixed union.
providerOptions: { anthropic: { thinking: { type: "automatic" } } },
})
@@ -0,0 +1,13 @@
import { LLM } from "../../src"
import { Azure } from "../../src/providers"
const model = Azure.configure({ resourceName: "example" }).responses("deployment")
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { store: false } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Azure OpenAI store must be boolean.
providerOptions: { openai: { store: "false" } },
})
@@ -0,0 +1,13 @@
import { LLM } from "../../src"
import { CloudflareWorkersAI } from "../../src/providers"
const model = CloudflareWorkersAI.configure({ accountId: "account", apiKey: "test" }).model("model")
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { promptCacheKey: "cache" } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Cloudflare's OpenAI-compatible prompt cache key must be a string.
providerOptions: { openai: { promptCacheKey: 1 } },
})
@@ -0,0 +1,13 @@
import { LLM } from "../../src"
import { GitHubCopilot } from "../../src/providers"
const model = GitHubCopilot.configure({ baseURL: "https://example.com" }).model("gpt-5")
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { reasoningSummary: "auto" } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Copilot reasoning summaries use the OpenAI union.
providerOptions: { openai: { reasoningSummary: "full" } },
})
@@ -0,0 +1,13 @@
import { LLM } from "../../src"
import { GoogleVertexChat } from "../../src/providers"
const model = GoogleVertexChat.configure({ accessToken: "test", project: "project" }).model("gemini")
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { serviceTier: "priority" } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Vertex OpenAI-compatible service tiers use the OpenAI union.
providerOptions: { openai: { serviceTier: "premium" } },
})
@@ -0,0 +1,13 @@
import { LLM } from "../../src"
import { GoogleVertexMessages } from "../../src/providers"
const model = GoogleVertexMessages.configure({ accessToken: "test", project: "project" }).model("claude")
LLM.request({ model, prompt: "Hello", providerOptions: { anthropic: { effort: "medium" } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Vertex Anthropic effort must be a string.
providerOptions: { anthropic: { effort: false } },
})
@@ -0,0 +1,13 @@
import { LLM } from "../../src"
import { GoogleVertexResponses } from "../../src/providers"
const model = GoogleVertexResponses.configure({ accessToken: "test", project: "project" }).model("gemini")
LLM.request({ model, prompt: "Hello", providerOptions: { openresponses: { textVerbosity: "high" } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Vertex Responses verbosity uses the Open Responses union.
providerOptions: { openresponses: { textVerbosity: "verbose" } },
})
@@ -0,0 +1,17 @@
import { LLM } from "../../src"
import { GoogleVertex } from "../../src/providers"
const model = GoogleVertex.provider.configure({ apiKey: "test" }).model("gemini-2.5-pro")
LLM.request({
model,
prompt: "Hello",
providerOptions: { gemini: { thinkingConfig: { includeThoughts: true } } },
})
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Vertex Gemini includeThoughts must be boolean.
providerOptions: { gemini: { thinkingConfig: { includeThoughts: "yes" } } },
})
@@ -0,0 +1,47 @@
import { LLM } from "../../src"
import { Google } from "../../src/providers"
const model = Google.provider.model("gemini-2.5-pro")
LLM.request({
model,
prompt: "Hello",
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 1024 } } },
})
LLM.request({
model,
prompt: "Hello",
providerOptions: {
gemini: {
// @ts-expect-error Gemini safety settings require a threshold for every category.
safetySettings: [{ category: "HARM_CATEGORY_HATE_SPEECH" }],
},
},
})
LLM.request({
model,
prompt: "Hello",
providerOptions: {
gemini: {
cachedContent: "cachedContents/example",
safetySettings: [{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" }],
serviceTier: "future-tier",
thinkingConfig: { thinkingLevel: "high", includeThoughts: true },
},
},
})
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Gemini thinking budgets must be numeric.
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "large" } } },
})
LLM.request({
model,
prompt: "Hello",
providerOptions: { gemini: { thinkingConfig: { thinkingLevel: "maximum" } } },
})
@@ -0,0 +1,13 @@
import { LLM } from "../../src"
import { OpenAICompatibleResponses } from "../../src/providers"
const model = OpenAICompatibleResponses.configure({ baseURL: "https://example.com" }).model("model")
LLM.request({ model, prompt: "Hello", providerOptions: { openresponses: { reasoningSummary: "detailed" } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Open Responses reasoning summaries use a fixed union.
providerOptions: { openresponses: { reasoningSummary: "full" } },
})
@@ -0,0 +1,13 @@
import { LLM } from "../../src"
import { OpenAICompatible } from "../../src/providers"
const model = OpenAICompatible.deepseek.model("deepseek-chat")
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { store: false } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error OpenAI-compatible store must be boolean.
providerOptions: { openai: { store: "false" } },
})
@@ -0,0 +1,13 @@
import { LLM } from "../../src"
import { OpenAI } from "../../src/providers"
const model = OpenAI.responses("gpt-5")
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { reasoningEffort: "high" } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error OpenAI reasoning effort must be a string.
providerOptions: { openai: { reasoningEffort: 1 } },
})
@@ -0,0 +1,35 @@
import { LLM } from "../../src"
import { OpenRouter } from "../../src/providers"
const model = OpenRouter.provider.model("anthropic/claude-sonnet-4.5")
LLM.request({ model, prompt: "Hello", providerOptions: { openrouter: { usage: true } } })
LLM.request({
model,
prompt: "Hello",
providerOptions: {
openrouter: {
models: ["google/gemini-3.1-pro"],
provider: {
order: ["anthropic"],
require_parameters: true,
data_collection: "future-policy",
sort: "future-sort",
max_price: { prompt: "0.50" },
},
reasoning: { effort: "future-effort", exclude: false },
plugins: [{ id: "future-plugin", enabled: true }],
web_search_options: { engine: "future-engine" },
debug: { echo_upstream_body: true },
user: "user_123",
},
},
})
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error OpenRouter usage must be boolean or an option record.
providerOptions: { openrouter: { usage: "yes" } },
})
@@ -0,0 +1,13 @@
import { LLM } from "../../src"
import { XAI } from "../../src/providers"
const model = XAI.provider.model("grok-4")
LLM.request({ model, prompt: "Hello", providerOptions: { xai: { reasoningEffort: "high" } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error xAI's OpenAI-compatible reasoning effort must be a string.
providerOptions: { xai: { reasoningEffort: true } },
})
+35
View File
@@ -21,12 +21,47 @@ describe("provider package entrypoints", () => {
import("@opencode-ai/ai/providers/google-vertex/chat"), import("@opencode-ai/ai/providers/google-vertex/chat"),
import("@opencode-ai/ai/providers/google-vertex/responses"), import("@opencode-ai/ai/providers/google-vertex/responses"),
import("@opencode-ai/ai/providers/google-vertex/messages"), import("@opencode-ai/ai/providers/google-vertex/messages"),
import("@opencode-ai/ai/providers/openrouter"),
import("@opencode-ai/ai/providers/xai"),
import("@opencode-ai/ai/providers/amazon-bedrock/mantle"),
import("@opencode-ai/ai/providers/amazon-bedrock/mantle/chat"),
import("@opencode-ai/ai/providers/amazon-bedrock/mantle/responses"),
]) ])
for (const module of modules) expect(module.model).toBeFunction() for (const module of modules) expect(module.model).toBeFunction()
expect(modules[0].model).toBe(modules[1].model) expect(modules[0].model).toBe(modules[1].model)
expect(modules[8].model).toBe(modules[9].model) expect(modules[8].model).toBe(modules[9].model)
expect(modules[12].model).toBe(modules[13].model) expect(modules[12].model).toBe(modules[13].model)
expect(modules[19].model).toBe(modules[20].model)
})
test("maps OpenRouter and xAI package settings onto executable models", async () => {
const OpenRouter = await import("@opencode-ai/ai/providers/openrouter")
const XAI = await import("@opencode-ai/ai/providers/xai")
const settings = {
apiKey: "fixture",
baseURL: "https://provider.example.test/v1",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
limits: { context: 200_000, output: 64_000 },
}
const openrouter = OpenRouter.model("anthropic/claude-sonnet-4", {
...settings,
providerOptions: { openrouter: { usage: true } },
})
const xai = XAI.model("grok-4", {
...settings,
providerOptions: { xai: { reasoningEffort: "high" } },
})
for (const selected of [openrouter, xai]) {
expect(selected.route.endpoint.baseURL).toBe(settings.baseURL)
expect(selected.route.defaults.headers).toEqual(settings.headers)
expect(selected.route.defaults.http?.body).toEqual(settings.body)
expect(selected.route.defaults.limits).toEqual(settings.limits)
}
expect(openrouter.route.defaults.providerOptions).toEqual({ openrouter: { usage: true } })
expect(xai.route.defaults.providerOptions).toMatchObject({ xai: { reasoningEffort: "high", store: false } })
}) })
test("maps package settings onto the executable model", () => { test("maps package settings onto the executable model", () => {

Some files were not shown because too many files have changed in this diff Show More