@bitkyc08/opencodex 2.7.40 → 2.7.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/README.md +63 -12
  2. package/assets/claude-code-models.gif +0 -0
  3. package/gui/dist/assets/index-B2J4t3te.css +1 -0
  4. package/gui/dist/assets/index-BmvM6wRb.js +65 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +3 -2
  7. package/src/adapters/google.ts +48 -9
  8. package/src/adapters/kiro-events.ts +15 -3
  9. package/src/adapters/kiro.ts +292 -28
  10. package/src/adapters/openai-chat.ts +63 -15
  11. package/src/adapters/openai-responses.ts +18 -0
  12. package/src/bridge.ts +76 -21
  13. package/src/chat/outbound.ts +66 -34
  14. package/src/claude/auth-detect.ts +229 -0
  15. package/src/claude/auth-mode-migration.ts +32 -0
  16. package/src/claude/auth-mode.ts +62 -0
  17. package/src/claude/desktop-3p-guard.ts +35 -0
  18. package/src/claude/desktop-3p.ts +121 -21
  19. package/src/claude/desktop-health.ts +26 -0
  20. package/src/claude/desktop-profile.ts +263 -0
  21. package/src/claude/inbound-debug.ts +4 -0
  22. package/src/claude/model-info.ts +9 -3
  23. package/src/cli/account-extended.ts +34 -0
  24. package/src/cli/account.ts +3 -1
  25. package/src/cli/claude-desktop.ts +152 -0
  26. package/src/cli/claude.ts +37 -3
  27. package/src/cli/doctor.ts +187 -6
  28. package/src/cli/help.ts +13 -1
  29. package/src/cli/index.ts +151 -57
  30. package/src/cli/status-oauth.ts +68 -0
  31. package/src/cli/status.ts +4 -0
  32. package/src/codex/account-lifecycle.ts +31 -0
  33. package/src/codex/auth-api.ts +133 -26
  34. package/src/codex/auth-collision.ts +55 -11
  35. package/src/codex/auth-context.ts +50 -6
  36. package/src/codex/catalog/provider-fetch.ts +6 -2
  37. package/src/codex/home.ts +61 -1
  38. package/src/codex/inject.ts +28 -66
  39. package/src/codex/injected-marker.ts +72 -0
  40. package/src/codex/journal.ts +39 -3
  41. package/src/codex/main-account-cache.ts +25 -0
  42. package/src/codex/model-cache.ts +20 -1
  43. package/src/codex/paths.ts +5 -0
  44. package/src/codex/routing.ts +138 -11
  45. package/src/codex/subagent-model-fallback.ts +455 -0
  46. package/src/codex/sync.ts +17 -0
  47. package/src/combos/failover.ts +10 -2
  48. package/src/combos/index.ts +1 -0
  49. package/src/combos/types.ts +9 -0
  50. package/src/config.ts +117 -0
  51. package/src/grok/inject.ts +339 -0
  52. package/src/grok/status.ts +88 -0
  53. package/src/grok/sync.ts +66 -0
  54. package/src/lib/destination-policy.ts +13 -0
  55. package/src/lib/errors.ts +59 -0
  56. package/src/lib/privacy.ts +9 -0
  57. package/src/lib/process-control.ts +57 -4
  58. package/src/oauth/health.ts +375 -0
  59. package/src/oauth/index.ts +69 -22
  60. package/src/oauth/kiro.ts +5 -3
  61. package/src/oauth/log.ts +48 -0
  62. package/src/oauth/store.ts +55 -6
  63. package/src/providers/alibaba-region-backup.ts +75 -0
  64. package/src/providers/alibaba-region-migration.ts +143 -0
  65. package/src/providers/alibaba-region-startup.ts +36 -0
  66. package/src/providers/api-keys.ts +5 -5
  67. package/src/providers/derive.ts +22 -1
  68. package/src/providers/free-directory.ts +181 -0
  69. package/src/providers/key-failover.ts +2 -2
  70. package/src/providers/kiro-models.ts +3 -2
  71. package/src/providers/openai-tiers.ts +1 -1
  72. package/src/providers/provider-id-rewrite.ts +150 -0
  73. package/src/providers/registry.ts +49 -2
  74. package/src/responses/parser.ts +49 -20
  75. package/src/responses/state.ts +156 -2
  76. package/src/router.ts +73 -5
  77. package/src/server/chat-completions.ts +78 -22
  78. package/src/server/claude-messages.ts +8 -0
  79. package/src/server/images.ts +2 -1
  80. package/src/server/index.ts +50 -30
  81. package/src/server/live.ts +2 -1
  82. package/src/server/management/agent-settings-routes.ts +337 -18
  83. package/src/server/management/api-access.ts +141 -0
  84. package/src/server/management/combo-routes.ts +3 -3
  85. package/src/server/management/config-routes.ts +4 -4
  86. package/src/server/management/logs-usage-routes.ts +64 -4
  87. package/src/server/management/model-routes.ts +114 -8
  88. package/src/server/management/oauth-account-routes.ts +31 -5
  89. package/src/server/management/provider-routes.ts +42 -11
  90. package/src/server/management/shared.ts +81 -2
  91. package/src/server/management/system-routes.ts +6 -1
  92. package/src/server/management-api.ts +24 -7
  93. package/src/server/port-reclaim.ts +261 -0
  94. package/src/server/request-log.ts +11 -5
  95. package/src/server/responses/collaboration.ts +11 -6
  96. package/src/server/responses/compact.ts +2 -1
  97. package/src/server/responses/core.ts +358 -155
  98. package/src/server/responses/passthrough-error.ts +53 -0
  99. package/src/server/search.ts +2 -1
  100. package/src/server/system-env.ts +23 -6
  101. package/src/server/windows-tcp-drop.ts +174 -0
  102. package/src/service.ts +43 -3
  103. package/src/types.ts +74 -6
  104. package/src/update/index.ts +30 -19
  105. package/src/update/job.ts +30 -15
  106. package/src/usage/log.ts +158 -2
  107. package/src/usage/summary.ts +8 -4
  108. package/src/web-search/loop.ts +4 -1
  109. package/gui/dist/assets/index-CMip1DzF.css +0 -1
  110. package/gui/dist/assets/index-cydcmbzC.js +0 -52
@@ -0,0 +1,181 @@
1
+ export type ProviderAccessGroup =
2
+ | "recurring-or-keyless"
3
+ | "recurring-uncapped"
4
+ | "recurring-credit"
5
+ | "signup-credit";
6
+
7
+ export const FREE_PROVIDER_ACCESS_GROUPS = {
8
+ "recurring-or-keyless": [
9
+ "agy", "aihorde", "api-airforce", "arcee-ai", "bazaarlink", "blackbox", "bluesminds", "cerebras",
10
+ "cloudflare-ai", "cohere", "coze", "duckduckgo-web", "felo-web", "friendliai", "gemini", "github-models",
11
+ "groq", "hackclub", "huggingchat", "huggingface", "iflytek", "inference-net", "kiro", "liquid", "llm7",
12
+ "mistral", "morph", "muse-spark-web", "nara", "navy", "nlpcloud", "ollama-cloud", "opencode", "openrouter",
13
+ "ovhcloud", "pollinations", "puter", "qwen-web", "reka", "sambanova", "sparkdesk", "t3-web", "uncloseai",
14
+ ],
15
+ "recurring-uncapped": [
16
+ "agnes", "ainative", "aion", "baidu", "glm", "glm-cn", "kilo-gateway", "opencode-zen", "requesty",
17
+ "routeway", "sealion", "siliconflow", "tencent",
18
+ ],
19
+ "recurring-credit": ["bytez", "nous-research"],
20
+ "signup-credit": [
21
+ "agentrouter", "ai21", "baichuan", "deepinfra", "deepseek", "doubao", "fireworks", "freemodel-dev", "glm-cn",
22
+ "hyperbolic", "longcat", "monsterapi", "nebius", "novita", "nscale", "nvidia", "predibase", "publicai", "qoder",
23
+ "scaleway", "sensenova", "stepfun", "together", "vertex",
24
+ ],
25
+ } as const satisfies Record<ProviderAccessGroup, readonly string[]>;
26
+
27
+ export interface FreeDirectoryProvider {
28
+ id: string;
29
+ label: string;
30
+ adapter: string;
31
+ baseUrl: string;
32
+ authKind: "oauth" | "key";
33
+ accessGroups: readonly ProviderAccessGroup[];
34
+ supportLevel: "supported" | "experimental" | "reference";
35
+ verification: "official" | "primary" | "unverified";
36
+ documentationUrl?: string;
37
+ modelsUrl?: string;
38
+ discovery: "live" | "static" | "hybrid" | "unsupported";
39
+ /**
40
+ * Date the endpoint was checked against provider documentation. Only rows that were actually
41
+ * verified carry it — a uniform date on an `unverified` row is a claim nobody made.
42
+ */
43
+ lastVerified?: string;
44
+ dashboardUrl?: string;
45
+ keyOptional?: boolean;
46
+ models?: string[];
47
+ liveModels: boolean;
48
+ note?: string;
49
+ googleMode?: "ai-studio" | "vertex";
50
+ }
51
+
52
+ type ConnectableOverride = Partial<Omit<FreeDirectoryProvider, "id" | "accessGroups">>;
53
+
54
+ const LAST_VERIFIED = "2026-07-23";
55
+ /**
56
+ * Connectable rows carry `lastVerified` because their endpoint was checked against provider
57
+ * documentation on that date. Reference rows built by the generator below deliberately omit it:
58
+ * stamping a verification date on a row marked `unverified` asserts provenance nobody produced.
59
+ */
60
+ const openAi = (baseUrl: string, dashboardUrl: string, extra: Partial<ConnectableOverride> = {}): ConnectableOverride => ({
61
+ baseUrl,
62
+ dashboardUrl,
63
+ adapter: "openai-chat",
64
+ authKind: "key",
65
+ supportLevel: "experimental",
66
+ verification: "primary",
67
+ discovery: "live",
68
+ liveModels: true,
69
+ lastVerified: LAST_VERIFIED,
70
+ ...extra,
71
+ });
72
+
73
+ // API roots are limited to documented or primary-source integrations. Consumer-web/session
74
+ // providers remain reference-only: this directory never asks users to paste cookies or bypass WAFs.
75
+ const CONNECTABLE: Record<string, ConnectableOverride> = {
76
+ kiro: { baseUrl: "https://runtime.us-east-1.kiro.dev", dashboardUrl: "https://kiro.dev", adapter: "kiro", authKind: "oauth", supportLevel: "supported", verification: "official", documentationUrl: "https://kiro.dev/docs/cli/authentication/", lastVerified: LAST_VERIFIED, discovery: "static", liveModels: false },
77
+ aihorde: openAi("https://oai.aihorde.net/v1", "https://aihorde.net", { modelsUrl: "https://oai.aihorde.net/v1/models" }),
78
+ "arcee-ai": openAi("https://conductor.arcee.ai/v1", "https://conductor.arcee.ai", { verification: "official", documentationUrl: "https://docs.arcee.ai/" }),
79
+ cerebras: openAi("https://api.cerebras.ai/v1", "https://cloud.cerebras.ai/platform/apikeys", { supportLevel: "supported", verification: "official", documentationUrl: "https://inference-docs.cerebras.ai/api-reference/models/list" }),
80
+ "cloudflare-ai": openAi("https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/v1", "https://dash.cloudflare.com/?to=/:account/ai/workers-ai", { supportLevel: "supported", verification: "official", documentationUrl: "https://developers.cloudflare.com/workers-ai/configuration/open-ai-compatibility/", discovery: "static", liveModels: false, models: ["@cf/meta/llama-3.3-70b-instruct-fp8-fast", "@cf/qwen/qwq-32b"] }),
81
+ cohere: openAi("https://api.cohere.com/compatibility/v1", "https://dashboard.cohere.com/api-keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.cohere.com/reference/list-models", modelsUrl: "https://api.cohere.com/compatibility/v1/models" }),
82
+ friendliai: openAi("https://api.friendli.ai/serverless/v1", "https://suite.friendli.ai", { modelsUrl: "https://api.friendli.ai/serverless/v1/models" }),
83
+ gemini: { baseUrl: "https://generativelanguage.googleapis.com", dashboardUrl: "https://aistudio.google.com/apikey", adapter: "google", authKind: "key", supportLevel: "supported", verification: "official", documentationUrl: "https://ai.google.dev/api/models", lastVerified: LAST_VERIFIED, discovery: "live", liveModels: true, googleMode: "ai-studio", models: ["gemini-3.6-flash", "gemini-3.5-flash", "gemini-3.5-flash-lite", "gemini-3.1-pro-preview"] },
84
+ "github-models": openAi("https://models.github.ai/inference", "https://github.com/settings/tokens", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.github.com/en/github-models/prototyping-with-ai-models", discovery: "static", liveModels: false, models: ["openai/gpt-4.1", "meta/llama-4-scout-17b-16e-instruct"] }),
85
+ groq: openAi("https://api.groq.com/openai/v1", "https://console.groq.com/keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://console.groq.com/docs/api-reference#models" }),
86
+ hackclub: openAi("https://ai.hackclub.com/proxy/v1", "https://ai.hackclub.com", { modelsUrl: "https://ai.hackclub.com/proxy/v1/models" }),
87
+ huggingface: openAi("https://router.huggingface.co/v1", "https://huggingface.co/settings/tokens", { supportLevel: "supported", verification: "official", documentationUrl: "https://huggingface.co/docs/inference-providers/index", modelsUrl: "https://router.huggingface.co/v1/models" }),
88
+ // The OpenAI-compatible base URL is documented in the HTTP guide; Web.html covers the
89
+ // WebSocket protocol and does not describe this endpoint.
90
+ iflytek: openAi("https://spark-api-open.xf-yun.com/v1", "https://console.xfyun.cn", { verification: "official", documentationUrl: "https://www.xfyun.cn/doc/spark/HTTP%E8%B0%83%E7%94%A8%E6%96%87%E6%A1%A3.html" }),
91
+ liquid: openAi("https://inference.liquid.ai/v1", "https://playground.liquid.ai", { modelsUrl: "https://inference.liquid.ai/v1/models" }),
92
+ mistral: openAi("https://api.mistral.ai/v1", "https://console.mistral.ai/api-keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.mistral.ai/api/endpoint/models" }),
93
+ morph: openAi("https://api.morphllm.com/v1", "https://morphllm.com", { verification: "official", documentationUrl: "https://docs.morphllm.com/api-reference/introduction" }),
94
+ "ollama-cloud": openAi("https://ollama.com/v1", "https://ollama.com/settings/keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.ollama.com/cloud", modelsUrl: "https://ollama.com/api/tags" }),
95
+ opencode: openAi("https://opencode.ai/zen/v1", "https://opencode.ai/auth", { supportLevel: "supported", verification: "official", documentationUrl: "https://opencode.ai/docs/zen/", modelsUrl: "https://opencode.ai/zen/v1/models" }),
96
+ openrouter: openAi("https://openrouter.ai/api/v1", "https://openrouter.ai/keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://openrouter.ai/docs/api/api-reference/models/get-models" }),
97
+ ovhcloud: openAi("https://oai.endpoints.kepler.ai.cloud.ovh.net/v1", "https://console.ovhcloud.com", { keyOptional: true, modelsUrl: "https://oai.endpoints.kepler.ai.cloud.ovh.net/v1/models" }),
98
+ pollinations: openAi("https://gen.pollinations.ai/v1", "https://pollinations.ai", { keyOptional: true }),
99
+ reka: openAi("https://api.reka.ai/v1", "https://platform.reka.ai"),
100
+ sambanova: openAi("https://api.sambanova.ai/v1", "https://cloud.sambanova.ai/apis", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.sambanova.ai/cloud/docs/api-reference/models" }),
101
+ sparkdesk: openAi("https://spark-api-open.xf-yun.com/v1", "https://console.xfyun.cn", { verification: "official", documentationUrl: "https://www.xfyun.cn/doc/spark/HTTP%E8%B0%83%E7%94%A8%E6%96%87%E6%A1%A3.html" }),
102
+ agnes: openAi("https://apihub.agnes-ai.com/v1", "https://agnes-ai.com"),
103
+ ainative: openAi("https://api.ainative.studio/api/v1", "https://ainative.studio", { modelsUrl: "https://api.ainative.studio/api/v1/models" }),
104
+ aion: openAi("https://api.aionlabs.ai/v1", "https://aionlabs.ai", { modelsUrl: "https://api.aionlabs.ai/v1/models" }),
105
+ baidu: openAi("https://qianfan.baidubce.com/v2", "https://console.bce.baidu.com/qianfan/ais/console/applicationConsole/application", { verification: "official", documentationUrl: "https://cloud.baidu.com/doc/WENXINWORKSHOP/s/Fm2vrveyu", discovery: "static", liveModels: false, models: ["ernie-5.1", "ernie-5.0", "ernie-4.5-turbo-128k"] }),
106
+ glm: openAi("https://api.z.ai/api/coding/paas/v4", "https://z.ai/manage-apikey/apikey-list", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.z.ai/guides/overview/quick-start", discovery: "static", liveModels: false, models: ["glm-5"] }),
107
+ "glm-cn": openAi("https://open.bigmodel.cn/api/coding/paas/v4", "https://open.bigmodel.cn/usercenter/apikeys", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.bigmodel.cn/cn/api/introduction", discovery: "static", liveModels: false, models: ["glm-4.5-flash"] }),
108
+ "kilo-gateway": openAi("https://api.kilo.ai/api/gateway", "https://kilo.ai", { modelsUrl: "https://api.kilo.ai/api/gateway/models" }),
109
+ "opencode-zen": openAi("https://opencode.ai/zen/v1", "https://opencode.ai/auth", { supportLevel: "supported", verification: "official", documentationUrl: "https://opencode.ai/docs/zen/" }),
110
+ requesty: openAi("https://router.requesty.ai/v1", "https://app.requesty.ai", { modelsUrl: "https://router.requesty.ai/v1/models" }),
111
+ routeway: openAi("https://api.routeway.ai/v1", "https://routeway.ai", { modelsUrl: "https://api.routeway.ai/v1/models" }),
112
+ sealion: openAi("https://api.sea-lion.ai/v1", "https://sea-lion.ai", { discovery: "static", liveModels: false, models: ["aisingapore/Llama-SEA-LION-v3.5-70B-R", "aisingapore/Llama-SEA-LION-v3-70B-IT", "aisingapore/Gemma-SEA-LION-v4-27B-IT", "aisingapore/Qwen-SEA-LION-v4.5-27B-IT", "aisingapore/Qwen-SEA-LION-v4-32B-IT"] }),
113
+ siliconflow: openAi("https://api.siliconflow.cn/v1", "https://cloud.siliconflow.cn/account/ak", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.siliconflow.cn/en/api-reference/models/get-model-list" }),
114
+ tencent: openAi("https://api.hunyuan.cloud.tencent.com/v1", "https://console.cloud.tencent.com/hunyuan/api-key", { verification: "official", documentationUrl: "https://cloud.tencent.com/document/product/1729/111007", discovery: "static", liveModels: false, models: ["hunyuan-turbos-latest", "hunyuan-t1-latest", "hunyuan-pro", "hunyuan-lite"] }),
115
+ // Endpoint is live, but the recurring-credit terms could not be confirmed, so this row stays
116
+ // `unverified` and drops the shared verification date rather than borrowing it.
117
+ bytez: openAi("https://api.bytez.com/models/v2/openai/v1", "https://bytez.com", { verification: "unverified", lastVerified: undefined, documentationUrl: "https://docs.bytez.com/", discovery: "static", liveModels: false, models: ["meta-llama/Llama-3.3-70B-Instruct", "mistralai/Mistral-7B-Instruct-v0.3", "Qwen/Qwen2.5-72B-Instruct"], note: "The recurring-credit classification is retained from the requested catalog, but the current reset terms could not be independently verified." }),
118
+ "nous-research": openAi("https://inference-api.nousresearch.com/v1", "https://portal.nousresearch.com", { discovery: "static", liveModels: false, models: ["Hermes-4-405B", "Hermes-4-70B"] }),
119
+ agentrouter: { baseUrl: "https://agentrouter.org", dashboardUrl: "https://agentrouter.org", adapter: "anthropic", authKind: "key", supportLevel: "experimental", verification: "primary", modelsUrl: "https://agentrouter.org/v1/models", lastVerified: LAST_VERIFIED, discovery: "live", liveModels: true },
120
+ ai21: openAi("https://api.ai21.com/studio/v1", "https://studio.ai21.com/account/api-key", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.ai21.com/reference/models" }),
121
+ baichuan: openAi("https://api.baichuan-ai.com/v1", "https://platform.baichuan-ai.com/console/apikey", { verification: "official" }),
122
+ deepinfra: openAi("https://api.deepinfra.com/v1/openai", "https://deepinfra.com/dash/api_keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://deepinfra.com/docs/openai_api" }),
123
+ deepseek: openAi("https://api.deepseek.com", "https://platform.deepseek.com/api_keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://api-docs.deepseek.com/api/list-models" }),
124
+ doubao: openAi("https://ark.cn-beijing.volces.com/api/v3", "https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey", { verification: "official" }),
125
+ fireworks: openAi("https://api.fireworks.ai/inference/v1", "https://fireworks.ai/account/api-keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.fireworks.ai/api-reference/list-models", modelsUrl: "https://api.fireworks.ai/v1/accounts/fireworks/models?filter=supports_serverless=true" }),
126
+ "freemodel-dev": openAi("https://api.freemodel.dev/v1", "https://freemodel.dev", { modelsUrl: "https://api.freemodel.dev/v1/models" }),
127
+ hyperbolic: openAi("https://api.hyperbolic.xyz/v1", "https://app.hyperbolic.xyz/settings", { verification: "official" }),
128
+ longcat: openAi("https://api.longcat.chat/openai/v1", "https://longcat.chat", { verification: "official", discovery: "static", liveModels: false, models: ["LongCat-2.0"] }),
129
+ monsterapi: openAi("https://api.monsterapi.ai/v1", "https://monsterapi.ai", { verification: "official" }),
130
+ nebius: openAi("https://api.tokenfactory.nebius.com/v1", "https://studio.nebius.com", { verification: "official" }),
131
+ novita: openAi("https://api.novita.ai/openai/v1", "https://novita.ai/settings/key-management", { supportLevel: "supported", verification: "official", modelsUrl: "https://api.novita.ai/openai/v1/models" }),
132
+ nscale: openAi("https://inference.api.nscale.com/v1", "https://console.nscale.com", { verification: "official" }),
133
+ nvidia: openAi("https://integrate.api.nvidia.com/v1", "https://build.nvidia.com", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.api.nvidia.com/nim/reference/llm-apis" }),
134
+ publicai: openAi("https://api.publicai.co/v1", "https://publicai.co"),
135
+ scaleway: openAi("https://api.scaleway.ai/v1", "https://console.scaleway.com/generative-api", { verification: "official" }),
136
+ sensenova: openAi("https://token.sensenova.cn/v1", "https://console.sensenova.cn", { verification: "official" }),
137
+ stepfun: openAi("https://api.stepfun.com/v1", "https://platform.stepfun.com", { verification: "official" }),
138
+ together: openAi("https://api.together.xyz/v1", "https://api.together.xyz/settings/api-keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.together.ai/reference/models-1" }),
139
+ vertex: { baseUrl: "https://aiplatform.googleapis.com", dashboardUrl: "https://console.cloud.google.com/vertex-ai", adapter: "google", authKind: "key", supportLevel: "experimental", verification: "official", documentationUrl: "https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference", lastVerified: LAST_VERIFIED, discovery: "static", liveModels: false, googleMode: "vertex", models: ["gemini-3.1-pro-preview", "gemini-3.1-flash-lite", "gemini-3-flash-preview"], note: "Vertex accepts an express-mode API key here. ADC users can instead configure project and location through the existing Google Vertex provider." },
140
+ };
141
+
142
+ const LABELS: Record<string, string> = {
143
+ agy: "AGY", aihorde: "AI Horde", "api-airforce": "API Airforce", "arcee-ai": "Arcee AI",
144
+ bazaarlink: "BazaarLink", blackbox: "Blackbox", bluesminds: "Bluesminds", "cloudflare-ai": "Cloudflare AI",
145
+ coze: "Coze", "duckduckgo-web": "DuckDuckGo Web", "felo-web": "Felo Web", friendliai: "FriendliAI",
146
+ gemini: "Google Gemini", "github-models": "GitHub Models", huggingchat: "HuggingChat", huggingface: "Hugging Face",
147
+ iflytek: "iFlytek", "inference-net": "Inference.net", llm7: "LLM7", "muse-spark-web": "Muse Spark Web",
148
+ nlpcloud: "NLP Cloud", "ollama-cloud": "Ollama Cloud", ovhcloud: "OVHcloud", "qwen-web": "Qwen Web",
149
+ "t3-web": "T3 Web", uncloseai: "UncloseAI", ainative: "AI Native", baidu: "Baidu Qianfan",
150
+ glm: "Z.AI GLM", "glm-cn": "BigModel GLM (CN)", "kilo-gateway": "Kilo Gateway", "opencode-zen": "OpenCode Zen",
151
+ sealion: "SEA-LION", bytez: "Bytez", "nous-research": "Nous Research", agentrouter: "AgentRouter",
152
+ ai21: "AI21", baichuan: "Baichuan", deepinfra: "DeepInfra", deepseek: "DeepSeek", doubao: "Doubao",
153
+ "freemodel-dev": "FreeModel.dev", nebius: "Nebius", novita: "Novita", nscale: "Nscale", nvidia: "NVIDIA NIM",
154
+ publicai: "PublicAI", qoder: "Qoder", sensenova: "SenseNova", stepfun: "StepFun", vertex: "Google Vertex AI",
155
+ };
156
+
157
+ const referenceNote = "Reference entry only: no safe documented API integration is enabled. Configure it manually only with provider documentation; consumer-web cookies and anti-bot bypasses are intentionally unsupported.";
158
+
159
+ export const FREE_PROVIDER_DIRECTORY: readonly FreeDirectoryProvider[] = Object.values(FREE_PROVIDER_ACCESS_GROUPS)
160
+ .flat()
161
+ .filter((id, index, ids) => ids.indexOf(id) === index)
162
+ .map(id => {
163
+ const accessGroups = (Object.entries(FREE_PROVIDER_ACCESS_GROUPS) as [ProviderAccessGroup, readonly string[]][])
164
+ .filter(([, ids]) => ids.includes(id))
165
+ .map(([group]) => group);
166
+ const override = CONNECTABLE[id];
167
+ return {
168
+ id,
169
+ label: LABELS[id] ?? id.split("-").map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(" "),
170
+ adapter: "openai-chat",
171
+ baseUrl: "",
172
+ authKind: "key",
173
+ accessGroups,
174
+ supportLevel: "reference",
175
+ verification: "unverified",
176
+ discovery: "unsupported",
177
+ liveModels: false,
178
+ ...(!override ? { note: referenceNote } : {}),
179
+ ...override,
180
+ } satisfies FreeDirectoryProvider;
181
+ });
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * Modelled after src/codex/routing.ts cooldown logic but scoped to plain API-key pools.
10
10
  */
11
- import { saveConfig } from "../config";
11
+ import { saveConfigPreservingClaudeCode } from "../config";
12
12
  import type { OcxConfig, OcxProviderConfig } from "../types";
13
13
  import { resolveProviderTransport } from "./xai-transport";
14
14
 
@@ -112,7 +112,7 @@ export function rotateKeyOn429(
112
112
  if (!isKeyInCooldown(providerName, candidate.id, now)) {
113
113
  // Swap active key
114
114
  provider.apiKey = candidate.key;
115
- saveConfig(config);
115
+ saveConfigPreservingClaudeCode(config);
116
116
  console.warn(
117
117
  // Log ids only — labels are user-supplied free text and could carry secret material.
118
118
  `[key-failover] ${providerName}: 429 on key ${currentEntry?.id ?? "?"}; rotating to key ${candidate.id}`,
@@ -47,8 +47,9 @@ export const KIRO_MODEL_CONTEXT_WINDOWS: Record<string, number> = {
47
47
 
48
48
  const KIRO_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
49
49
 
50
- // gpt-5.6-sol sends these values through Kiro's verified native reasoning field. Other models map
51
- // them to bounded thinking instructions until their native effort support is verified.
50
+ // gpt-5.6-sol and claude-opus-5 send these values through Kiro's verified native effort fields
51
+ // (`reasoning.effort` and `output_config.effort` respectively). Other models map them to bounded
52
+ // thinking instructions until their native effort support is verified.
52
53
  export const KIRO_MODEL_REASONING_EFFORTS: Record<string, string[]> = Object.fromEntries(
53
54
  KIRO_MODELS.map(id => [id, KIRO_REASONING_EFFORTS]),
54
55
  );
@@ -6,7 +6,7 @@ export const LEGACY_OPENAI_MULTI_PROVIDER_ID = "openai-multi";
6
6
  export const OPENAI_API_PROVIDER_ID = "openai-apikey";
7
7
  export const LEGACY_CHATGPT_PROVIDER_ID = "chatgpt";
8
8
 
9
- const CODEX_FORWARD_BASE_URL = "https://chatgpt.com/backend-api/codex";
9
+ export const CODEX_FORWARD_BASE_URL = "https://chatgpt.com/backend-api/codex";
10
10
  const LEGACY_OPENAI_MULTI_PREFIX = `${LEGACY_OPENAI_MULTI_PROVIDER_ID}/`;
11
11
 
12
12
  function canonicalCodexForwardProvider(mode: CodexAccountMode): OcxProviderConfig {
@@ -0,0 +1,150 @@
1
+ import type { OcxConfig } from "../types";
2
+
3
+ export interface ProviderRewriteResult {
4
+ /** Number of references re-pointed. */
5
+ changed: number;
6
+ /**
7
+ * Sites where the destination key already held a value, left untouched. A
8
+ * non-empty list means the caller must not treat the rewrite as complete —
9
+ * merging two settings is not a decision this function can make. The function
10
+ * is NOT transactional: earlier sites are already rewritten by the time a
11
+ * collision is found, so a caller that receives collisions must discard the
12
+ * config it passed in.
13
+ */
14
+ collisions: string[];
15
+ }
16
+
17
+ /**
18
+ * Re-point every config reference from one provider id to another.
19
+ *
20
+ * Three shapes exist and the difference matters: routed model strings
21
+ * (`"<provider>/<model>"`), bare provider ids (`customModels[].provider`,
22
+ * `combos[*].targets[].provider`), and keys that ARE provider ids or routes
23
+ * (`providerContextCaps`, `claudeCode.desktopProfile.assignments`). A rewrite
24
+ * that handles only the first leaves an orphaned context cap and — worse — a
25
+ * combo target naming a provider that no longer exists, which fails validation
26
+ * in `src/combos/types.ts` and makes `loadConfig` discard the whole config.
27
+ *
28
+ * `providers[*].selectedModels` is deliberately NOT rewritten: those are
29
+ * per-provider native model ids, and upstream ids may themselves contain a
30
+ * slash, so a prefix rewrite could mangle an unrelated provider's allowlist. A
31
+ * caller that moves a provider row handles its own allowlist, where the
32
+ * destination catalog is known.
33
+ */
34
+ export function rewriteProviderReferences(config: OcxConfig, from: string, to: string): ProviderRewriteResult {
35
+ const prefix = `${from}/`;
36
+ const collisions: string[] = [];
37
+ let changed = 0;
38
+
39
+ /** Routed string: rewrite only an exact `<from>/…` prefix, never a longer id. */
40
+ const route = (value: unknown): string | undefined => {
41
+ if (typeof value !== "string" || !value.startsWith(prefix)) return undefined;
42
+ changed += 1;
43
+ return `${to}/${value.slice(prefix.length)}`;
44
+ };
45
+
46
+ /**
47
+ * Rewrite a routed-string list in place. Assigning the result unconditionally
48
+ * would add an own property with value `undefined` where the field was absent,
49
+ * which breaks the no-op contract. The key type is an explicit union rather
50
+ * than `keyof OcxConfig`: the latter also admits `customModels` and friends, so
51
+ * `map` would infer a union array that is not assignable back.
52
+ */
53
+ type RoutedListKey = "disabledModels" | "subagentModels" | "subagentModelFallback";
54
+ const routeListAt = (key: RoutedListKey): void => {
55
+ const list = config[key];
56
+ if (!list) return;
57
+ config[key] = list.map(id => route(id) ?? id);
58
+ };
59
+
60
+ const routeRecordValues = (record: Record<string, string> | undefined): void => {
61
+ if (!record) return;
62
+ for (const [key, value] of Object.entries(record)) {
63
+ const next = route(value);
64
+ if (next) record[key] = next;
65
+ }
66
+ };
67
+
68
+ if (config.defaultProvider === from) {
69
+ config.defaultProvider = to;
70
+ changed += 1;
71
+ }
72
+
73
+ routeListAt("disabledModels");
74
+ routeListAt("subagentModels");
75
+ routeListAt("subagentModelFallback");
76
+
77
+ const scalarOwners: Array<[Record<string, unknown> | undefined, string]> = [
78
+ [config as unknown as Record<string, unknown>, "injectionModel"],
79
+ [config.shadowCallIntercept as Record<string, unknown> | undefined, "model"],
80
+ [config.webSearchSidecar as Record<string, unknown> | undefined, "model"],
81
+ [config.visionSidecar as Record<string, unknown> | undefined, "model"],
82
+ [config.claudeCode as unknown as Record<string, unknown> | undefined, "model"],
83
+ [config.claudeCode as unknown as Record<string, unknown> | undefined, "smallFastModel"],
84
+ [config.claudeCode?.webSearchSidecar as Record<string, unknown> | undefined, "model"],
85
+ [config.claudeCode?.visionSidecar as Record<string, unknown> | undefined, "model"],
86
+ ];
87
+ for (const [owner, key] of scalarOwners) {
88
+ if (!owner) continue;
89
+ const next = route(owner[key]);
90
+ if (next) owner[key] = next;
91
+ }
92
+
93
+ routeRecordValues(config.claudeCode?.tierModels as Record<string, string> | undefined);
94
+ routeRecordValues(config.claudeCode?.modelMap as Record<string, string> | undefined);
95
+
96
+ // Bare provider ids.
97
+ for (const model of config.customModels ?? []) {
98
+ if (model.provider === from) {
99
+ model.provider = to;
100
+ changed += 1;
101
+ }
102
+ }
103
+ for (const combo of Object.values(config.combos ?? {})) {
104
+ for (const target of combo.targets ?? []) {
105
+ if (target.provider === from) {
106
+ target.provider = to;
107
+ changed += 1;
108
+ }
109
+ }
110
+ }
111
+
112
+ // Keys. `providerContextCaps` is KEYED by provider id — a prefix rewrite would
113
+ // silently orphan the cap — and a destination key may already be occupied.
114
+ const caps = config.providerContextCaps;
115
+ if (caps && Object.hasOwn(caps, from)) {
116
+ if (Object.hasOwn(caps, to)) {
117
+ collisions.push(`providerContextCaps.${to}`);
118
+ } else {
119
+ caps[to] = caps[from]!;
120
+ delete caps[from];
121
+ changed += 1;
122
+ }
123
+ }
124
+
125
+ // `desktopProfile` is nested and asymmetric: `assignments` is KEYED by route
126
+ // while `defaults` holds routes as VALUES.
127
+ const profile = config.claudeCode?.desktopProfile;
128
+ if (profile) {
129
+ for (const key of Object.keys(profile.assignments ?? {})) {
130
+ const next = route(key);
131
+ if (!next) continue;
132
+ if (Object.hasOwn(profile.assignments, next)) {
133
+ changed -= 1; // `route` counted it; the move did not happen.
134
+ collisions.push(`claudeCode.desktopProfile.assignments.${next}`);
135
+ continue;
136
+ }
137
+ profile.assignments[next] = profile.assignments[key]!;
138
+ delete profile.assignments[key];
139
+ }
140
+ const defaults = profile.defaults as Record<string, string | null> | undefined;
141
+ if (defaults) {
142
+ for (const [family, value] of Object.entries(defaults)) {
143
+ const next = route(value);
144
+ if (next) defaults[family] = next;
145
+ }
146
+ }
147
+ }
148
+
149
+ return { changed, collisions };
150
+ }
@@ -71,6 +71,7 @@ export interface ProviderRegistryEntry {
71
71
  promptCacheKey?: boolean;
72
72
  autoToolChoiceOnlyModels?: string[];
73
73
  preserveReasoningContentModels?: string[];
74
+ reasoningSplitModels?: string[];
74
75
  thinkingToggleModels?: string[];
75
76
  thinkingBudgetModels?: string[];
76
77
  escapeBuiltinToolNames?: boolean;
@@ -92,7 +93,7 @@ export type ProviderConfigSeed = Pick<
92
93
  | "modelMaxInputTokens" | "defaultMaxOutputTokens" | "modelMaxOutputTokens"
93
94
  | "reasoningEfforts" | "modelReasoningEfforts" | "modelDefaultReasoningEfforts" | "reasoningEffortMap" | "modelReasoningEffortMap"
94
95
  | "noVisionModels" | "noReasoningModels" | "noTemperatureModels" | "noTopPModels" | "noPenaltyModels"
95
- | "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" | "thinkingToggleModels" | "thinkingBudgetModels" | "escapeBuiltinToolNames"
96
+ | "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" | "reasoningSplitModels" | "thinkingToggleModels" | "thinkingBudgetModels" | "escapeBuiltinToolNames"
96
97
  | "googleMode" | "project" | "location" | "headers"
97
98
  >;
98
99
 
@@ -117,6 +118,16 @@ const MINIMAX_MODELS = [
117
118
  const MINIMAX_MODEL_CONTEXT_WINDOWS: Record<string, number> = Object.fromEntries(
118
119
  MINIMAX_MODELS.map(id => [id, id === "MiniMax-M3" ? 1_000_000 : 204_800]),
119
120
  );
121
+ const MINIMAX_M3_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
122
+ const MINIMAX_M3_REASONING_EFFORT_MAP: Record<string, string> = {
123
+ none: "disabled",
124
+ minimal: "disabled",
125
+ low: "disabled",
126
+ medium: "adaptive",
127
+ high: "adaptive",
128
+ xhigh: "adaptive",
129
+ max: "adaptive",
130
+ };
120
131
  const OPENAI_GPT56_MODELS = ["gpt-5.6", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"];
121
132
  const OPENAI_GPT56_PRO_MODELS = ["gpt-5.6-sol-pro", "gpt-5.6-terra-pro", "gpt-5.6-luna-pro"];
122
133
  const OPENAI_API_GPT56_CONTEXT_WINDOW = 1_050_000;
@@ -390,6 +401,18 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
390
401
  // transport returns 400 ("Multi Agent requests are not allowed on chat completions").
391
402
  models: ["grok-4.5", "grok-4.3", "grok-4.20-0309-reasoning", "grok-4.20-0309-non-reasoning", "grok-build-0.1", "grok-composer-2.5-fast"],
392
403
  defaultModel: "grok-4.5",
404
+ // Vision lineup per docs.x.ai model-capabilities/images/understanding: the grok-4.x chat
405
+ // models accept image input (JPEG/PNG, URL or base64). Without this the catalog leaves
406
+ // inputModalities undefined, and deriveComboCatalogModel defaults an undefined member to
407
+ // ["text"] — so any combo containing an xAI target is advertised to Codex as text-only and
408
+ // the app blocks attachments client-side. grok-build-0.1 / grok-composer-2.5-fast stay out
409
+ // (they are already listed in noVisionModels below).
410
+ modelInputModalities: {
411
+ "grok-4.5": ["text", "image"],
412
+ "grok-4.3": ["text", "image"],
413
+ "grok-4.20-0309-reasoning": ["text", "image"],
414
+ "grok-4.20-0309-non-reasoning": ["text", "image"],
415
+ },
393
416
  noReasoningModels: ["grok-4.20-0309-non-reasoning", "grok-build-0.1", "grok-composer-2.5-fast"],
394
417
  // Replay assistant reasoning_content for grok reasoning models: xAI documents dropped
395
418
  // reasoning_content as the top cause of prompt-cache misses on multi-turn conversations
@@ -470,7 +493,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
470
493
  baseUrl: "https://runtime.us-east-1.kiro.dev",
471
494
  authKind: "oauth",
472
495
  oauthId: "kiro",
473
- note: "Import-first: reuses your installed kiro-cli login (no browser). Experimental third-party harness — see Kiro ToS.",
496
+ note: "Import-first: reuses your installed Kiro CLI login — requires kiro-cli installed and signed in (`kiro-cli login`). Experimental third-party harness — see Kiro ToS.",
474
497
  models: KIRO_MODELS,
475
498
  defaultModel: "kiro-auto",
476
499
  // Kiro speaks CodeWhisperer wire, not OpenAI-style GET /models. Keep the static
@@ -644,6 +667,18 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
644
667
  preserveReasoningContentModels: ["deepseek/deepseek-v4-pro"],
645
668
  note: "OpenAI-compatible adaptive router. Default is a tool-capable model; orcarouter/auto (adaptive routing) is also selectable. Full catalog: https://www.orcarouter.ai/models",
646
669
  },
670
+ {
671
+ // BizRouter: Korean enterprise LLM gateway (api.bizrouter.ai). Model ids are
672
+ // vendor-namespaced (`<vendor>/<model>`) and pass through to the upstream as-is.
673
+ // Live-verified 2026-07-24: /v1/chat/completions accepts the `tools` field and
674
+ // streams, and GET /v1/models returns the per-API-key allowed catalog in the
675
+ // OpenAI list shape, so live model discovery narrows to what the key can use.
676
+ id: "bizrouter", label: "BizRouter", adapter: "openai-chat", baseUrl: "https://api.bizrouter.ai/v1",
677
+ authKind: "key", dashboardUrl: "https://bizrouter.ai/settings/keys",
678
+ defaultModel: "openai/gpt-5.6-sol",
679
+ models: ["openai/gpt-5.6-sol", "anthropic/claude-sonnet-5", "google/gemini-3.5-flash"],
680
+ note: "Korean enterprise LLM gateway. Per-key allowed models are discovered live from /v1/models. Full catalog: https://bizrouter.ai/models",
681
+ },
647
682
  { id: "groq", label: "Groq", adapter: "openai-chat", baseUrl: "https://api.groq.com/openai/v1", authKind: "key", featured: true, dashboardUrl: "https://console.groq.com/keys" },
648
683
  // 2026-07-10 Gemini API refresh: Tier-2 ai.google.dev evidence recorded in
649
684
  // devlog/_plan/260710_provider_hardening/001_research_frontier.md.
@@ -908,12 +943,24 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
908
943
  id: "minimax", label: "MiniMax — Coding Plan", baseUrl: "https://api.minimax.io/v1", adapter: "openai-chat", authKind: "key",
909
944
  dashboardUrl: "https://platform.minimax.io", defaultModel: "MiniMax-M3", models: MINIMAX_MODELS,
910
945
  modelContextWindows: MINIMAX_MODEL_CONTEXT_WINDOWS,
946
+ modelReasoningEfforts: { "MiniMax-M3": MINIMAX_M3_REASONING_EFFORTS },
947
+ modelDefaultReasoningEfforts: { "MiniMax-M3": "medium" },
948
+ modelReasoningEffortMap: { "MiniMax-M3": MINIMAX_M3_REASONING_EFFORT_MAP },
949
+ preserveReasoningContentModels: MINIMAX_MODELS,
950
+ reasoningSplitModels: MINIMAX_MODELS,
951
+ thinkingToggleModels: ["MiniMax-M3"],
911
952
  jawcodeBundle: "minimax", metadataModelIdNormalize: "case-insensitive", note: "Subscription Key or API Key",
912
953
  },
913
954
  {
914
955
  id: "minimax-cn", label: "MiniMax — Coding Plan (CN)", baseUrl: "https://api.minimaxi.com/v1", adapter: "openai-chat", authKind: "key",
915
956
  dashboardUrl: "https://platform.minimaxi.com", defaultModel: "MiniMax-M3", models: MINIMAX_MODELS,
916
957
  modelContextWindows: MINIMAX_MODEL_CONTEXT_WINDOWS,
958
+ modelReasoningEfforts: { "MiniMax-M3": MINIMAX_M3_REASONING_EFFORTS },
959
+ modelDefaultReasoningEfforts: { "MiniMax-M3": "medium" },
960
+ modelReasoningEffortMap: { "MiniMax-M3": MINIMAX_M3_REASONING_EFFORT_MAP },
961
+ preserveReasoningContentModels: MINIMAX_MODELS,
962
+ reasoningSplitModels: MINIMAX_MODELS,
963
+ thinkingToggleModels: ["MiniMax-M3"],
917
964
  jawcodeBundle: "minimax", metadataModelIdNormalize: "case-insensitive", note: "中国区 Subscription Key",
918
965
  },
919
966
  {
@@ -25,28 +25,54 @@ type InputBlock =
25
25
  | { type: "input_text"; text: string }
26
26
  | { type: "text"; text: string }
27
27
  | { type: "input_image"; image_url?: string; file_id?: string; detail?: string }
28
- | { type: "input_file"; file_id?: string; filename?: string };
28
+ | { type: "input_file"; file_id?: string; filename?: string; file_data?: string };
29
29
 
30
- function inputContentParts(blocks: unknown[] | string | undefined): string | OcxContentPart[] {
30
+ /** A usable reference string, or undefined. Empty strings and non-strings are not references. */
31
+ function nonEmptyString(value: unknown): string | undefined {
32
+ return typeof value === "string" && value.length > 0 ? value : undefined;
33
+ }
34
+
35
+ function inputContentParts(blocks: unknown): string | OcxContentPart[] {
31
36
  if (typeof blocks === "string") return blocks;
32
- if (!blocks) return [];
37
+ // The catch-all can also hand back a non-array `content` (an object, a number), which would
38
+ // throw at the loop below before any per-block guard runs.
39
+ if (!Array.isArray(blocks)) return [];
33
40
  const parts: OcxContentPart[] = [];
34
41
  for (const raw of blocks) {
42
+ // A malformed message item fails its strict schema and falls through to inputItemSchema's
43
+ // permissive catch-all, so blocks reaching here are NOT guaranteed to match the declared
44
+ // shape. Validate each field before use, as outputToToolResultContent already does.
45
+ if (!isObj(raw)) continue;
35
46
  const block = raw as InputBlock;
36
47
  if (block.type === "input_text" || block.type === "text") {
37
- parts.push({ type: "text", text: (block as { text: string }).text });
48
+ if (typeof raw.text === "string") parts.push({ type: "text", text: raw.text });
38
49
  } else if (block.type === "input_image") {
39
50
  const b = block as { image_url?: string; file_id?: string; detail?: string };
40
- if (b.image_url) {
51
+ const imageUrl = nonEmptyString(b.image_url);
52
+ const fileId = nonEmptyString(b.file_id);
53
+ const detail = nonEmptyString(b.detail);
54
+ if (imageUrl) {
41
55
  // Preserve the image as a structured part — adapters send it as a native image block.
42
56
  // NEVER inline the (often base64 data-URL) image_url as text: that explodes the token count.
43
- parts.push({ type: "image", imageUrl: b.image_url, ...(b.detail ? { detail: normalizeImageDetail(b.detail) } : {}) });
44
- } else {
45
- parts.push({ type: "text", text: `[image: ${b.file_id ?? "?"}]` }); // file_id ref → no inline data
57
+ parts.push({ type: "image", imageUrl, ...(detail ? { detail: normalizeImageDetail(detail) } : {}) });
58
+ } else if (fileId) {
59
+ parts.push({ type: "text", text: `[image: ${fileId}]` }); // file_id ref → no inline data
46
60
  }
61
+ // No usable reference: omit the block. A "[image: ?]" marker would claim an attachment
62
+ // the request never carried, which is worse than dropping malformed input.
47
63
  } else if (block.type === "input_file") {
48
- const ref = (block as { file_id?: string; filename?: string }).file_id ?? (block as { filename?: string }).filename ?? "?";
49
- parts.push({ type: "text", text: `[file: ${ref}]` });
64
+ const b = block as { file_id?: string; filename?: string; file_data?: string };
65
+ const fileId = nonEmptyString(b.file_id);
66
+ const fileData = nonEmptyString(b.file_data);
67
+ const filename = nonEmptyString(b.filename);
68
+ if (fileId) {
69
+ parts.push({ type: "text", text: `[file: ${fileId}]` });
70
+ } else if (fileData) {
71
+ // Inline file_data is often large base64. Preserve only its presence and name, never bytes.
72
+ parts.push({ type: "text", text: filename ? `[file: ${filename}]` : "[file: inline data]" });
73
+ }
74
+ // A bare filename is not a file resource in the Responses schema, so omit it rather than
75
+ // fabricating a "[file: ...]" marker for an attachment that was never sent.
50
76
  }
51
77
  }
52
78
  // Collapse to a plain string only for a single TEXT part; images must stay structured.
@@ -56,14 +82,19 @@ function inputContentParts(blocks: unknown[] | string | undefined): string | Ocx
56
82
 
57
83
  type OutputBlock = { type: "output_text"; text: string } | { type: "text"; text: string } | { type: "refusal"; refusal: string };
58
84
 
59
- function outputTextOf(blocks: unknown[] | string | undefined): OcxTextContent[] {
85
+ function outputTextOf(blocks: unknown): OcxTextContent[] {
60
86
  if (typeof blocks === "string") return blocks.length > 0 ? [{ type: "text", text: blocks }] : [];
61
- if (!blocks) return [];
87
+ if (!Array.isArray(blocks)) return [];
62
88
  const out: OcxTextContent[] = [];
63
89
  for (const raw of blocks) {
90
+ // Same catch-all caveat as inputContentParts: validate before use.
91
+ if (!isObj(raw)) continue;
64
92
  const b = raw as OutputBlock;
65
- if (b.type === "output_text" || b.type === "text") out.push({ type: "text", text: (b as { text: string }).text });
66
- else if (b.type === "refusal") out.push({ type: "text", text: `[refusal: ${(b as { refusal: string }).refusal}]` });
93
+ if (b.type === "output_text" || b.type === "text") {
94
+ if (typeof raw.text === "string") out.push({ type: "text", text: raw.text });
95
+ } else if (b.type === "refusal") {
96
+ if (typeof raw.refusal === "string") out.push({ type: "text", text: `[refusal: ${raw.refusal}]` });
97
+ }
67
98
  }
68
99
  return out;
69
100
  }
@@ -311,9 +342,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
311
342
  content?: unknown;
312
343
  };
313
344
 
314
- const content = inputContentParts(
315
- agentMessage.content as unknown[] | string | undefined,
316
- );
345
+ const content = inputContentParts(agentMessage.content);
317
346
 
318
347
  const hasContent =
319
348
  typeof content === "string"
@@ -338,7 +367,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
338
367
  switch (msg.role) {
339
368
  case "system": {
340
369
  pendingReasoning.length = 0;
341
- const text = inputContentParts(msg.content as unknown[] | string | undefined);
370
+ const text = inputContentParts(msg.content);
342
371
  const flat = typeof text === "string" ? text : text.map(p => (p.type === "text" ? p.text : "")).join("");
343
372
  if (flat.length > 0) systemPrompt.push(flat);
344
373
  break;
@@ -346,12 +375,12 @@ export function parseRequest(body: unknown): OcxParsedRequest {
346
375
  case "user":
347
376
  case "developer": {
348
377
  pendingReasoning.length = 0;
349
- const content = inputContentParts(msg.content as unknown[] | string | undefined);
378
+ const content = inputContentParts(msg.content);
350
379
  messages.push({ role: msg.role, content, timestamp: now });
351
380
  break;
352
381
  }
353
382
  case "assistant": {
354
- const parts = outputTextOf(msg.content as unknown[] | string | undefined);
383
+ const parts = outputTextOf(msg.content);
355
384
  messages.push({
356
385
  role: "assistant",
357
386
  content: pendingReasoning.length > 0