@bitkyc08/opencodex 2.52.0-preview.20260911 → 2.52.0-preview.20260912

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 (166) hide show
  1. package/gui/dist/assets/index-D_t6sCWs.js +115 -0
  2. package/gui/dist/assets/index-EdoPnm9_.css +1 -0
  3. package/gui/dist/index.html +2 -2
  4. package/gui/dist/provider-icons/devin.svg +49 -0
  5. package/gui/dist/provider-icons/omo.svg +42 -0
  6. package/package.json +3 -1
  7. package/src/AGENTS.md +1 -1
  8. package/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +0 -1
  9. package/src/adapters/command-code.ts +0 -1
  10. package/src/adapters/cursor/checkpoint-store.ts +37 -0
  11. package/src/adapters/cursor/live-transport.ts +74 -2
  12. package/src/adapters/cursor.ts +6 -0
  13. package/src/adapters/devin/cloud-direct/auth.ts +264 -0
  14. package/src/adapters/devin/cloud-direct/catalog.ts +306 -0
  15. package/src/adapters/devin/cloud-direct/chat.ts +1274 -0
  16. package/src/adapters/devin/cloud-direct/index.ts +65 -0
  17. package/src/adapters/devin/cloud-direct/metadata.ts +134 -0
  18. package/src/adapters/devin/cloud-direct/wire.ts +206 -0
  19. package/src/adapters/devin/live-models.ts +133 -0
  20. package/src/adapters/devin-cli/acp.ts +204 -0
  21. package/src/adapters/devin-cli/adapter.ts +345 -0
  22. package/src/adapters/devin-cli/binary.ts +69 -0
  23. package/src/adapters/devin-cli/models.ts +57 -0
  24. package/src/adapters/devin.ts +326 -0
  25. package/src/adapters/google.ts +1 -1
  26. package/src/adapters/openai-chat.ts +31 -10
  27. package/src/adapters/openai-responses.ts +16 -1
  28. package/src/adapters/registry.ts +25 -1
  29. package/src/bridge.ts +8 -2
  30. package/src/claude/inbound-cache-stabilize.ts +130 -0
  31. package/src/claude/inbound.ts +45 -5
  32. package/src/cli/account-auth.ts +60 -1
  33. package/src/cli/account-extended.ts +23 -30
  34. package/src/cli/account.ts +2 -1
  35. package/src/cli/capabilities.ts +50 -8
  36. package/src/cli/config-command.ts +2 -2
  37. package/src/cli/dispatch.ts +14 -2
  38. package/src/cli/export-command.ts +11 -25
  39. package/src/cli/help.ts +2 -2
  40. package/src/cli/opencode.ts +5 -0
  41. package/src/cli/registry.ts +15 -4
  42. package/src/clients/config-export/cline.ts +71 -0
  43. package/src/clients/config-export/contracts.ts +10 -1
  44. package/src/clients/config-export/model-metadata.ts +33 -0
  45. package/src/clients/config-export/zcode.ts +23 -12
  46. package/src/clients/config-export.ts +156 -4
  47. package/src/codex/account-store.ts +7 -2
  48. package/src/codex/auth-context.ts +1 -1
  49. package/src/codex/catalog/effort.ts +4 -3
  50. package/src/codex/catalog/metadata.ts +8 -4
  51. package/src/codex/catalog/native-models.ts +24 -4
  52. package/src/codex/catalog/parsing.ts +19 -1
  53. package/src/codex/catalog/provider-fetch.ts +57 -0
  54. package/src/codex/catalog/sync.ts +1 -1
  55. package/src/codex/context-compat.ts +97 -0
  56. package/src/codex/context-owner.ts +201 -0
  57. package/src/codex/history-provider.ts +161 -14
  58. package/src/codex/inject-coordination.ts +3 -2
  59. package/src/codex/inject.ts +128 -9
  60. package/src/codex/pool-rotation.ts +8 -292
  61. package/src/codex/quota.ts +41 -12
  62. package/src/codex/retired-model-migration.ts +41 -0
  63. package/src/codex/routing.ts +327 -22
  64. package/src/codex/warmup.ts +2 -2
  65. package/src/combos/failover.ts +5 -0
  66. package/src/combos/resolve.ts +11 -15
  67. package/src/config.ts +46 -14
  68. package/src/generated/compatibility-version.json +293 -113
  69. package/src/grok/grpc-web.ts +120 -0
  70. package/src/grok/reset-coupon-ledger.ts +139 -0
  71. package/src/grok/reset-coupons.ts +278 -0
  72. package/src/integrations/catalog-refresh.ts +1 -1
  73. package/src/integrations/cline-document.ts +73 -0
  74. package/src/integrations/cline-io.ts +149 -0
  75. package/src/integrations/cline-transaction.ts +42 -0
  76. package/src/integrations/config-io.ts +21 -2
  77. package/src/integrations/journal.ts +43 -1
  78. package/src/integrations/omp-yaml-source.ts +123 -4
  79. package/src/integrations/ownership-policy.ts +7 -0
  80. package/src/integrations/ownership.ts +36 -0
  81. package/src/integrations/registry.ts +27 -0
  82. package/src/integrations/state.ts +5 -2
  83. package/src/integrations/store.ts +6 -0
  84. package/src/integrations/writer.ts +53 -9
  85. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  86. package/src/lib/abort.ts +36 -0
  87. package/src/lib/local-destinations.ts +1 -1
  88. package/src/lib/upstream-retry.ts +36 -1
  89. package/src/oauth/account-quota-rank.ts +11 -0
  90. package/src/oauth/callback-server.ts +10 -5
  91. package/src/oauth/devin/api-base.ts +63 -0
  92. package/src/oauth/devin/login.ts +1 -0
  93. package/src/oauth/devin/register-user.ts +186 -0
  94. package/src/oauth/devin/types.ts +71 -0
  95. package/src/oauth/devin-cli.ts +149 -0
  96. package/src/oauth/devin.ts +170 -0
  97. package/src/oauth/generic-account-failover.ts +169 -1
  98. package/src/oauth/index.ts +20 -1
  99. package/src/oauth/login-cli.ts +19 -5
  100. package/src/oauth/pool-kernel.ts +321 -0
  101. package/src/oauth/pool-settings-capability.ts +127 -9
  102. package/src/oauth/store.ts +7 -3
  103. package/src/oauth/token-guardian.ts +1 -1
  104. package/src/providers/codebuddy-models.ts +0 -3
  105. package/src/providers/command-code-efforts.ts +23 -4
  106. package/src/providers/default-aliases.ts +4 -0
  107. package/src/providers/derive.ts +8 -0
  108. package/src/providers/devin-cli-authmode-migration.ts +57 -0
  109. package/src/providers/key-failover.ts +175 -0
  110. package/src/providers/model-rename-startup.ts +21 -1
  111. package/src/providers/qoder-models.ts +0 -1
  112. package/src/providers/quota-key-accounts.ts +50 -0
  113. package/src/providers/quota-routing-cache.ts +65 -6
  114. package/src/providers/quota-types.ts +7 -0
  115. package/src/providers/quota.ts +113 -30
  116. package/src/providers/registry.ts +237 -77
  117. package/src/providers/stale-context-window-migration.ts +92 -0
  118. package/src/providers/zai-responses-migration.ts +45 -0
  119. package/src/quota/reset-observer.ts +2 -1
  120. package/src/quota/reset-seen-store.ts +9 -1
  121. package/src/remote-control/crypto.ts +442 -0
  122. package/src/remote-control/host.ts +175 -0
  123. package/src/remote-control/index.ts +100 -0
  124. package/src/remote-control/protocol.ts +200 -0
  125. package/src/remote-control/relay.ts +162 -0
  126. package/src/remote-control/workspace-agent-protocol.ts +246 -0
  127. package/src/remote-control/workspace-rpc-framing.ts +128 -0
  128. package/src/remote-control/workspace-tools.ts +237 -0
  129. package/src/remote-control/workspace-utf8.ts +24 -0
  130. package/src/responses/custom-tool-compat.ts +23 -0
  131. package/src/router.ts +6 -1
  132. package/src/routing/compatibility/behavior.ts +3 -0
  133. package/src/server/adapter-resolve.ts +6 -2
  134. package/src/server/auth-cors.ts +71 -6
  135. package/src/server/chat-completions.ts +5 -3
  136. package/src/server/chat-native.ts +9 -0
  137. package/src/server/claude-messages.ts +25 -30
  138. package/src/server/context-history.ts +207 -0
  139. package/src/server/images.ts +28 -1
  140. package/src/server/index.ts +42 -22
  141. package/src/server/live.ts +2 -1
  142. package/src/server/management/agent-settings-routes.ts +7 -1
  143. package/src/server/management/config-routes.ts +3 -3
  144. package/src/server/management/grok-coupon-routes.ts +287 -0
  145. package/src/server/management/integration-routes.ts +6 -1
  146. package/src/server/management/oauth-account-routes.ts +124 -7
  147. package/src/server/management/provider-routes.ts +77 -2
  148. package/src/server/management/route-registry.ts +6 -1
  149. package/src/server/management-api.ts +7 -0
  150. package/src/server/request-log.ts +4 -0
  151. package/src/server/responses/codex-ws-exchange.ts +90 -14
  152. package/src/server/responses/codex-ws-wire.ts +51 -1
  153. package/src/server/responses/compact.ts +8 -1
  154. package/src/server/responses/core.ts +188 -22
  155. package/src/server/responses/ws-upstream.ts +1 -1
  156. package/src/server/responses-undeclared-tool-guard.ts +261 -12
  157. package/src/server/zai-responses-startup.ts +21 -0
  158. package/src/types/config.ts +33 -3
  159. package/src/types/provider.ts +54 -4
  160. package/src/types/request.ts +1 -1
  161. package/src/types/tools.ts +36 -6
  162. package/src/update/job.ts +33 -11
  163. package/src/vision/plan.ts +40 -37
  164. package/src/web-search/index.ts +1 -0
  165. package/gui/dist/assets/index-BoBRSehJ.css +0 -1
  166. package/gui/dist/assets/index-Dx0xv2EA.js +0 -115
@@ -0,0 +1,326 @@
1
+ /**
2
+ * Devin / Cognition / Windsurf adapter.
3
+ *
4
+ * Uses the unofficial cloud-direct Connect-RPC client (GetChatMessage).
5
+ * OpenCodex injects the OAuth API key onto provider.apiKey
6
+ * before runTurn. This adapter maps OcxContext <-> ChatHistoryItem and
7
+ * streams CloudChatEvent into AdapterEvent.
8
+ */
9
+ import type { AdapterEvent, OcxAssistantMessage, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTool, OcxToolCall, OcxToolResultMessage, OcxUsage } from "../types";
10
+ import type { IncomingMeta, ProviderAdapter } from "./base";
11
+ import { streamChatEvents, allocateCascadeId, CloudChatError, type ChatHistoryItem, type ToolDef } from "./devin/cloud-direct";
12
+ import { getCachedCatalog } from "./devin/cloud-direct/catalog";
13
+ import { DEVIN_DEFAULT_API_SERVER, resolveDevinApiServer } from "../oauth/devin";
14
+
15
+ export const DEVIN_API_SERVER = DEVIN_DEFAULT_API_SERVER;
16
+
17
+ const EFFORT_SUFFIXES = new Set(["low", "medium", "high", "xhigh", "max", "none", "1m", "max-1m", "none-1m", "fast"]);
18
+
19
+ /**
20
+ * Cognition's catalog spells model ids with hyphens (`swe-1-7`), but the same
21
+ * models appear elsewhere - other proxies, hand-written config - with the dotted
22
+ * version number (`swe-1.7`). Left alone, a dotted id misses every catalog
23
+ * lookup and then gets an effort suffix appended to a name the server does not
24
+ * know, which Cognition answers with an opaque permission_denied.
25
+ */
26
+ export function normalizeDevinModelId(modelId: string): string {
27
+ return modelId.replace(/\./g, "-");
28
+ }
29
+
30
+ function hasEffortSuffix(modelId: string): boolean {
31
+ const parts = modelId.split("-");
32
+ return parts.length > 1 && EFFORT_SUFFIXES.has(parts[parts.length - 1]!);
33
+ }
34
+
35
+ /**
36
+ * Resolve the wire model UID using the live catalog as the source of truth.
37
+ * Cognition's catalog lists most models with an effort suffix
38
+ * (e.g. `gpt-5-6-sol-high`); the base id alone is not accepted for those.
39
+ *
40
+ * If the catalog is available: use the exact UID when it exists, otherwise
41
+ * append the reasoning effort (or `medium` default) and pick a variant the
42
+ * account actually has.
43
+ *
44
+ * If the catalog is unavailable (degraded mode): append the effort suffix
45
+ * for any base id that doesn't already carry one, mirroring the catalog shape.
46
+ */
47
+ async function resolveWireModelUid(
48
+ rawModelId: string,
49
+ apiKey: string,
50
+ host: string,
51
+ reasoningEffort?: string,
52
+ ): Promise<string> {
53
+ const modelId = normalizeDevinModelId(rawModelId);
54
+ if (hasEffortSuffix(modelId)) return modelId;
55
+ const catalog = await getCachedCatalog(apiKey, host);
56
+ if (catalog) {
57
+ if (catalog.byUid.has(modelId)) return modelId;
58
+ const effort = reasoningEffort && EFFORT_SUFFIXES.has(reasoningEffort) ? reasoningEffort : "medium";
59
+ const suffixed = `${modelId}-${effort}`;
60
+ if (catalog.byUid.has(suffixed)) return suffixed;
61
+ // Fall back to any enabled variant of this base model.
62
+ for (const uid of catalog.byUid.keys()) {
63
+ if (uid.startsWith(modelId + "-") && !catalog.byUid.get(uid)?.disabled) return uid;
64
+ }
65
+ }
66
+ // Degraded mode: append the default effort suffix.
67
+ const effort = reasoningEffort && EFFORT_SUFFIXES.has(reasoningEffort) ? reasoningEffort : "medium";
68
+ return `${modelId}-${effort}`;
69
+ }
70
+
71
+ export class DevinMissingCredentialError extends Error {
72
+ constructor() {
73
+ super("Devin live transport requires a Devin API key. Run ocx login devin to sign in with your Cognition/Devin account.");
74
+ this.name = "DevinMissingCredentialError";
75
+ }
76
+ }
77
+
78
+ export function resolveDevinToken(provider: OcxProviderConfig, headers?: Headers): string {
79
+ const providerKey = provider.apiKey?.trim();
80
+ if (providerKey) return providerKey;
81
+ const forwarded = headers?.get("authorization") ?? headers?.get("Authorization");
82
+ if (forwarded?.toLowerCase().startsWith("bearer ")) return forwarded.slice("bearer ".length).trim();
83
+ const envToken = process.env.OPENCODEX_DEVIN_TEST_TOKEN?.trim();
84
+ if (envToken) return envToken;
85
+ throw new DevinMissingCredentialError();
86
+ }
87
+
88
+ function textFromParts(content: string | OcxContentPart[] | undefined): string {
89
+ if (typeof content === "string") return content;
90
+ if (!Array.isArray(content)) return "";
91
+ return content.map((part) => (part.type === "text" ? part.text : "")).filter(Boolean).join("\n");
92
+ }
93
+
94
+ function toolResultText(message: OcxToolResultMessage): string {
95
+ const body = textFromParts(message.content);
96
+ return message.isError ? ("ERROR: " + body) : body;
97
+ }
98
+
99
+ function assistantToolCalls(message: OcxAssistantMessage): Array<{ id: string; name: string; arguments: string }> {
100
+ return message.content
101
+ .filter((part): part is OcxToolCall => part.type === "toolCall")
102
+ .map((part) => ({
103
+ id: part.id,
104
+ name: part.name,
105
+ arguments: JSON.stringify(part.arguments ?? {}),
106
+ }));
107
+ }
108
+
109
+ function assistantText(message: OcxAssistantMessage): string {
110
+ return message.content
111
+ // Thinking stays out of the replayed content. Cognition has no reasoning
112
+ // replay field, and folding chain-of-thought into assistant text sends it
113
+ // back as visible prior output - which the model then treats as something
114
+ // it said to the user.
115
+ .map((part) => (part.type === "text" ? part.text : ""))
116
+ .filter(Boolean)
117
+ .join("\n");
118
+ }
119
+
120
+ export function mapOcxMessagesToDevin(parsed: OcxParsedRequest): ChatHistoryItem[] {
121
+ const items: ChatHistoryItem[] = [];
122
+ const system = parsed.context.systemPrompt?.filter((line) => line.trim().length > 0).join("\n");
123
+ if (system) items.push({ role: "system", content: system });
124
+
125
+ for (const message of parsed.context.messages) {
126
+ const mapped = mapOneMessage(message);
127
+ if (mapped) items.push(mapped);
128
+ }
129
+ return items;
130
+ }
131
+
132
+ function mapOneMessage(message: OcxMessage): ChatHistoryItem | undefined {
133
+ if (message.role === "user" || message.role === "developer") {
134
+ const text = textFromParts(message.content).trim();
135
+ if (!text) return undefined;
136
+ return { role: message.role === "developer" ? "system" : "user", content: text };
137
+ }
138
+ if (message.role === "assistant") {
139
+ const toolCalls = assistantToolCalls(message);
140
+ const text = assistantText(message);
141
+ if (!text && toolCalls.length === 0) return undefined;
142
+ return {
143
+ role: "assistant",
144
+ content: text || "",
145
+ ...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
146
+ };
147
+ }
148
+ if (message.role === "toolResult") {
149
+ return {
150
+ role: "tool",
151
+ content: toolResultText(message),
152
+ tool_call_id: message.toolCallId,
153
+ };
154
+ }
155
+ return undefined;
156
+ }
157
+
158
+ export function mapOcxToolsToDevin(tools: OcxTool[] | undefined): ToolDef[] | undefined {
159
+ if (!tools || tools.length === 0) return undefined;
160
+ return tools.map((tool) => ({
161
+ name: tool.name,
162
+ description: tool.description ?? "",
163
+ parameters: tool.parameters ?? { type: "object", properties: {} },
164
+ }));
165
+ }
166
+
167
+ export function createDevinAdapter(
168
+ provider: OcxProviderConfig,
169
+ context: { providerId?: string } = {},
170
+ ): ProviderAdapter {
171
+ // Which credential slot holds this row's tenant. Defaults to `devin` so every
172
+ // existing caller — including the tests that construct this adapter directly —
173
+ // behaves exactly as before.
174
+ const credentialProviderId = context.providerId ?? "devin";
175
+ const cascadeIds = new Map<string, string>();
176
+ const CASCADE_ID_MAX = 256;
177
+
178
+ return {
179
+ name: "devin",
180
+
181
+ buildRequest() {
182
+ return {
183
+ url: provider.baseUrl || DEVIN_API_SERVER,
184
+ method: "POST",
185
+ headers: {},
186
+ body: "",
187
+ };
188
+ },
189
+
190
+ async *parseStream(): AsyncGenerator<AdapterEvent> {
191
+ yield {
192
+ type: "error",
193
+ message: "Devin adapter uses runTurn; the fetch/parseStream path is disabled.",
194
+ };
195
+ },
196
+
197
+ async runTurn(parsed: OcxParsedRequest, incoming: IncomingMeta, emit: (event: AdapterEvent) => void) {
198
+ if (incoming.abortSignal?.aborted) {
199
+ emit({ type: "error", message: "Devin turn was aborted before start." });
200
+ return;
201
+ }
202
+ let apiKey: string;
203
+ try {
204
+ apiKey = resolveDevinToken(provider, incoming.headers);
205
+ } catch (error) {
206
+ emit({ type: "error", message: error instanceof Error ? error.message : String(error) });
207
+ return;
208
+ }
209
+
210
+ const threadKey = parsed._clientThreadId || parsed.previousResponseId || "default";
211
+ let cascadeId = cascadeIds.get(threadKey);
212
+ if (!cascadeId) {
213
+ // Evict oldest entries to bound memory in long-running proxy processes.
214
+ if (cascadeIds.size >= CASCADE_ID_MAX) {
215
+ const firstKey = cascadeIds.keys().next().value;
216
+ if (firstKey) cascadeIds.delete(firstKey);
217
+ }
218
+ cascadeId = allocateCascadeId();
219
+ cascadeIds.set(threadKey, cascadeId);
220
+ }
221
+
222
+ const rawModelId = parsed.modelId.includes("/") ? parsed.modelId.slice(parsed.modelId.lastIndexOf("/") + 1) : parsed.modelId;
223
+ // The signed-in account's tenant decides the host, not the static registry
224
+ // entry: an EU or FedStart account that used provider.baseUrl would send
225
+ // every RPC to the US server it is not provisioned on.
226
+ const host = resolveDevinApiServer(provider.baseUrl, credentialProviderId);
227
+ const modelUid = await resolveWireModelUid(rawModelId, apiKey, host, parsed.options.reasoning);
228
+ let openToolId: string | undefined;
229
+ let usage: OcxUsage | undefined;
230
+ let stopReason: string | undefined;
231
+
232
+ const closeOpenTool = () => {
233
+ if (!openToolId) return;
234
+ emit({ type: "tool_call_end" });
235
+ openToolId = undefined;
236
+ };
237
+
238
+ try {
239
+ for await (const event of streamChatEvents({
240
+ apiKey,
241
+ apiServerUrl: host,
242
+ modelUid,
243
+ messages: mapOcxMessagesToDevin(parsed),
244
+ tools: mapOcxToolsToDevin(parsed.context.tools),
245
+ cascadeId,
246
+ // Without these the request falls back to the encoder's defaults
247
+ // (8192 output, a 128k context window, temperature 0.7), so a client
248
+ // that asked for a 4k cap never got one.
249
+ completionOpts: {
250
+ ...(typeof parsed.options.maxOutputTokens === "number" ? { maxOutputTokens: parsed.options.maxOutputTokens } : {}),
251
+ ...(typeof parsed.options.temperature === "number" ? { temperature: parsed.options.temperature } : {}),
252
+ ...(typeof parsed.options.topP === "number" ? { topP: parsed.options.topP } : {}),
253
+ },
254
+ signal: incoming.abortSignal,
255
+ })) {
256
+ if (incoming.abortSignal?.aborted) {
257
+ // Emitting nothing here left the bridge to synthesize adapter_eof.
258
+ // Say what happened instead, the way the other runTurn-only adapter
259
+ // does, and carry any usage already seen.
260
+ closeOpenTool();
261
+ emit({ type: "error", message: "Devin turn was aborted.", ...(usage ? { usage } : {}) });
262
+ return;
263
+ }
264
+ if (event.kind === "text") {
265
+ closeOpenTool();
266
+ if (event.text) emit({ type: "text_delta", text: event.text });
267
+ continue;
268
+ }
269
+ if (event.kind === "reasoning") {
270
+ if (event.text) emit({ type: "thinking_delta", thinking: event.text });
271
+ continue;
272
+ }
273
+ if (event.kind === "tool_call_start") {
274
+ closeOpenTool();
275
+ openToolId = event.id;
276
+ emit({ type: "tool_call_start", id: event.id, name: event.name });
277
+ continue;
278
+ }
279
+ if (event.kind === "tool_call_args") {
280
+ if (event.argsDelta) emit({ type: "tool_call_delta", arguments: event.argsDelta });
281
+ continue;
282
+ }
283
+ if (event.kind === "finish") {
284
+ closeOpenTool();
285
+ // A natural completion carries no stopReason: the bridge reads any
286
+ // truthy value as "this turn did not reach a final answer", so
287
+ // reporting "stop" costs every clean Devin turn its final_answer
288
+ // phase.
289
+ stopReason = event.reason === "length" ? "max_tokens" : event.reason === "stop" ? undefined : event.reason;
290
+ continue;
291
+ }
292
+ if (event.kind === "usage") {
293
+ const total = event.totalTokens ?? ((event.promptTokens ?? 0) + (event.completionTokens ?? 0));
294
+ usage = {
295
+ inputTokens: event.promptTokens ?? 0,
296
+ outputTokens: event.completionTokens ?? 0,
297
+ ...(total > 0 ? { totalTokens: total } : {}),
298
+ ...(event.cachedInputTokens !== undefined ? { cachedInputTokens: event.cachedInputTokens } : {}),
299
+ ...(event.cacheCreationInputTokens !== undefined ? { cacheCreationInputTokens: event.cacheCreationInputTokens } : {}),
300
+ ...(event.reasoningTokens !== undefined ? { reasoningOutputTokens: event.reasoningTokens } : {}),
301
+ };
302
+ continue;
303
+ }
304
+ }
305
+ closeOpenTool();
306
+ if (incoming.abortSignal?.aborted) {
307
+ emit({ type: "error", message: "Devin turn was aborted.", ...(usage ? { usage } : {}) });
308
+ } else {
309
+ emit({ type: "done", ...(usage ? { usage } : {}), ...(stopReason ? { stopReason } : {}) });
310
+ }
311
+ } catch (error) {
312
+ closeOpenTool();
313
+ if (incoming.abortSignal?.aborted) {
314
+ emit({ type: "error", message: "Devin turn was aborted.", ...(usage ? { usage } : {}) });
315
+ return;
316
+ }
317
+ const message = error instanceof CloudChatError
318
+ ? ("Devin cloud error" + (error.code ? " " + error.code : "") + ": " + error.message)
319
+ : error instanceof Error ? error.message : String(error);
320
+ // Usage that already arrived is still real; dropping it loses the
321
+ // accounting for a turn that did most of its work before failing.
322
+ emit({ type: "error", message, ...(usage ? { usage } : {}) });
323
+ }
324
+ },
325
+ };
326
+ }
@@ -109,7 +109,7 @@ function stripAntigravityRejectedClaudeSdkParagraph(systemText: string): string
109
109
  * Unknown ids return `undefined` deliberately. An earlier revision returned a 16,384 floor for
110
110
  * anything unmatched, which silently truncated aliases, gateway ids, and any model added after
111
111
  * this table was written — the operator asked for N tokens and got 16,384 with no signal. A cap
112
- * we cannot justify is worse than no cap: `structure/02_config-and-codex-home.md` is explicit
112
+ * we cannot justify is worse than no cap: `structure/config.md` is explicit
113
113
  * that an explicit request value wins, so an unrecognized model passes through untouched and the
114
114
  * upstream remains the authority on its own limit.
115
115
  *
@@ -96,7 +96,14 @@ function openAIChatTransport(provider: OcxProviderConfig): {
96
96
  };
97
97
  if (hasCredential) headers.Authorization = `Bearer ${provider.apiKey}`;
98
98
  if (provider.headers) Object.assign(headers, provider.headers);
99
- return { url: openaiChatCompletionsUrl(provider.baseUrl), headers, hasCredential };
99
+ // A configured relative path wins, mirroring how the Responses adapter honours
100
+ // `responsesPath`. An upstream can serve both wires under different prefixes, and a
101
+ // per-model wire override only swaps the adapter, so without this the opted-in Chat
102
+ // request would be sent to the Responses base with `/chat/completions` appended.
103
+ const url = provider.chatCompletionsPath === undefined
104
+ ? openaiChatCompletionsUrl(provider.baseUrl)
105
+ : `${provider.baseUrl.replace(/\/$/, "")}${provider.chatCompletionsPath}`;
106
+ return { url, headers, hasCredential };
100
107
  }
101
108
 
102
109
  /**
@@ -140,6 +147,16 @@ export function buildOpenAIChatPassthroughRequest(
140
147
  // ingress enforces exactly that. A prefix match here would strip response_format from
141
148
  // `<listed>:<tag>` siblings the operator never opted out, silently returning prose.
142
149
  if (provider.noStructuredOutputModels?.includes(modelId)) delete body.response_format;
150
+ // Narrower neighbour: the model takes `json_object` but rejects `json_schema`. Downgrade
151
+ // rather than drop, so a caller that asked for JSON still gets JSON. The type check also
152
+ // makes the kill switch above win without an else — after its `delete` there is no type
153
+ // left to match.
154
+ const passthroughFormat = body.response_format;
155
+ if (provider.noJsonSchemaModels?.includes(modelId)
156
+ && typeof passthroughFormat === "object" && passthroughFormat !== null
157
+ && (passthroughFormat as { type?: unknown }).type === "json_schema") {
158
+ body.response_format = { type: "json_object" };
159
+ }
143
160
 
144
161
  // Run the same complete Fast policy as the translated Chat path, including explicit
145
162
  // fastMode and foreign-tier handling. On inherited canonical Fast, the passthrough still
@@ -1582,15 +1599,19 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
1582
1599
  if (textFormat?.type === "json_object") {
1583
1600
  body.response_format = { type: "json_object" };
1584
1601
  } else if (textFormat?.type === "json_schema") {
1585
- body.response_format = {
1586
- type: "json_schema",
1587
- json_schema: {
1588
- name: textFormat.name ?? "response",
1589
- ...(textFormat.description !== undefined ? { description: textFormat.description } : {}),
1590
- ...(textFormat.schema !== undefined ? { schema: textFormat.schema } : {}),
1591
- ...(textFormat.strict !== undefined ? { strict: textFormat.strict } : {}),
1592
- },
1593
- };
1602
+ // Same downgrade as the passthrough path: the schema is dropped because the
1603
+ // upstream rejects it, but the JSON-mode request itself survives.
1604
+ body.response_format = provider.noJsonSchemaModels?.includes(parsed.modelId)
1605
+ ? { type: "json_object" }
1606
+ : {
1607
+ type: "json_schema",
1608
+ json_schema: {
1609
+ name: textFormat.name ?? "response",
1610
+ ...(textFormat.description !== undefined ? { description: textFormat.description } : {}),
1611
+ ...(textFormat.schema !== undefined ? { schema: textFormat.schema } : {}),
1612
+ ...(textFormat.strict !== undefined ? { strict: textFormat.strict } : {}),
1613
+ },
1614
+ };
1594
1615
  }
1595
1616
  }
1596
1617
 
@@ -1,4 +1,5 @@
1
1
  import { normalizeRoutedAgentMessages } from "./routed-agent-messages";
2
+ import { stripBracketedModelSuffix } from "./openai-chat";
2
3
  import { normalizeOpenCodeGoAdditionalTools } from "./opencode-go-additional-tools";
3
4
  import { isXaiResponsesDestination } from "../providers/xai-transport";
4
5
  import { createHash } from "node:crypto";
@@ -2541,7 +2542,21 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
2541
2542
  actualServiceTier === null ? null : "service-tier",
2542
2543
  actualServiceTier,
2543
2544
  );
2544
- const body = JSON.stringify(finalBody);
2545
+ // The Responses adapter is passthrough: it forwards `parsed._rawBody` rather than
2546
+ // rebuilding the body from `parsed.modelId`, and the router writes the routed id into
2547
+ // that raw body. So a provider whose upstream rejects bracketed ids has to be honoured
2548
+ // here, on the serialized body, not on the parsed selector. One place covers both the
2549
+ // HTTP and the WebSocket outbound, because the WS path transports this same request
2550
+ // instead of rebuilding it.
2551
+ const body = JSON.stringify(
2552
+ provider.modelSuffixBracketStrip
2553
+ && finalBody !== null
2554
+ && typeof finalBody === "object"
2555
+ && !Array.isArray(finalBody)
2556
+ && typeof (finalBody as { model?: unknown }).model === "string"
2557
+ ? { ...(finalBody as Record<string, unknown>), model: stripBracketedModelSuffix((finalBody as { model: string }).model) }
2558
+ : finalBody,
2559
+ );
2545
2560
  const releaseBodyObservation = translatorBudget.observeExternallyCapped(
2546
2561
  "passthrough_serialization",
2547
2562
  new TextEncoder().encode(body).byteLength,
@@ -6,6 +6,8 @@ import { createCodeBuddyAdapter } from "./codebuddy/adapter";
6
6
  import { createQoderAdapter } from "./qoder/adapter";
7
7
  import { createCommandCodeAdapter } from "./command-code";
8
8
  import { createCursorAdapter } from "./cursor";
9
+ import { createDevinCliAdapter } from "./devin-cli/adapter";
10
+ import { createDevinAdapter } from "./devin";
9
11
  import { createGoogleAdapter } from "./google";
10
12
  import { createKiroAdapter } from "./kiro";
11
13
  import { createMimoFreeAdapter } from "./mimo-free";
@@ -19,6 +21,16 @@ export type AdapterCacheRetention = "none" | "short" | "long";
19
21
 
20
22
  export interface AdapterFactoryContext {
21
23
  cacheRetention?: AdapterCacheRetention;
24
+ /**
25
+ * The configured provider row this adapter serves.
26
+ *
27
+ * Needed when one adapter backs two provider ids whose credentials differ:
28
+ * `devin` and `devin-cli` share a transport and a token format but sign in to
29
+ * different accounts and can sit on different Cognition tenants, and the tenant
30
+ * is recorded on the credential rather than in the registry. Optional, and
31
+ * every other adapter ignores it.
32
+ */
33
+ providerId?: string;
22
34
  }
23
35
 
24
36
  export type AdapterWire =
@@ -30,7 +42,9 @@ export type AdapterWire =
30
42
  | "openai-responses"
31
43
  | "google"
32
44
  | "kiro"
33
- | "cursor";
45
+ | "cursor"
46
+ | "devin-cli"
47
+ | "devin";
34
48
 
35
49
  export type AdapterMutationContract =
36
50
  | "codex-owned"
@@ -112,6 +126,16 @@ export const ADAPTER_REGISTRY = {
112
126
  mutation: "codex-owned-with-gated-native-fallback",
113
127
  create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createCursorAdapter(provider),
114
128
  },
129
+ "devin-cli": {
130
+ wire: "devin-cli",
131
+ mutation: "codex-owned",
132
+ create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createDevinCliAdapter(provider),
133
+ },
134
+ devin: {
135
+ wire: "devin",
136
+ mutation: "codex-owned",
137
+ create: (provider: OcxProviderConfig, context: AdapterFactoryContext) => createDevinAdapter(provider, context),
138
+ },
115
139
  "mimo-free": {
116
140
  contractParent: "openai-chat",
117
141
  create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createMimoFreeAdapter(provider),
package/src/bridge.ts CHANGED
@@ -1293,7 +1293,10 @@ export function bridgeToResponsesSSE(
1293
1293
  if (currentReasoning) closeCurrentReasoning();
1294
1294
  if (currentRawReasoning) closeCurrentRawReasoning();
1295
1295
  flushHiddenRawReasoning();
1296
- if (currentToolCall) closeCurrentToolCall();
1296
+ if (currentToolCall) {
1297
+ if (isTruncatedStopReason(event.stopReason)) failCurrentToolCall();
1298
+ else closeCurrentToolCall();
1299
+ }
1297
1300
  if (currentWebSearch) closeCurrentWebSearch("completed", []);
1298
1301
  releasePendingWebSources();
1299
1302
  // Redacted-only turns (or hidden thinking without a trailing signature event) still
@@ -2055,7 +2058,10 @@ function buildResponseJSONWithBudget(
2055
2058
  // must one left open by a stream that stopped without any terminal at all. That case previously
2056
2059
  // fell through to "completed", handing back a function_call whose arguments were half-written
2057
2060
  // JSON, inside a turn also marked completed.
2058
- if (currentToolCallId) flushToolCall(errorEvent || incompleteEvent || !sawTerminal ? "incomplete" : "completed");
2061
+ if (currentToolCallId) {
2062
+ flushToolCall(errorEvent || incompleteEvent || !sawTerminal || isTruncatedStopReason(rawStopReason)
2063
+ ? "incomplete" : "completed");
2064
+ }
2059
2065
  if (batchKiroRedacted) {
2060
2066
  // pushOutput reserves the item itself and releases the retained raw blob it replaces.
2061
2067
  pushOutput({
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Claude Code appends growing `<total_tokens>…</total_tokens>` footers (and
3
+ * occasional TaskCreate nudges) into system text that becomes Responses
4
+ * `instructions`. That churn breaks Muse/Go prefix cache on the instructions
5
+ * prefix even when tools stay stable. Strip dynamics from instructions;
6
+ * surface the latest notice on `input` instead.
7
+ *
8
+ * Relocation is identified by harness shape: only a trailing, unfenced,
9
+ * canonical notice at the end of instructions is moved. An unmatched fence
10
+ * opener covers through EOF. No match → the original string is returned
11
+ * byte-for-byte. The matcher is content identity only; `translateAnthropicRequest`
12
+ * requires `claudeCode.stabilizePromptCache: true` before this helper runs. Claude Code
13
+ * writes `<total_tokens>N tokens left</total_tokens>`.
14
+ *
15
+ * TaskCreate nudge text has drifted across Claude Code builds; match the
16
+ * known exact paragraphs (legacy + 2.1.263 "tracking progress" form) as
17
+ * trailing alternatives so a mid-session nudge cannot pin older footers
18
+ * above it and flip `instructions` after a previously stable peel.
19
+ */
20
+
21
+ const TOTAL_NOTICE_RE = /^<total_tokens>[0-9]+ tokens left<\/total_tokens>$/;
22
+
23
+ /** Legacy Claude Code TaskCreate reminder (pre-"tracking progress"). */
24
+ const TASKCREATE_NUDGE_LEGACY =
25
+ "The task tools haven't been used recently. If you're working on tasks that would benefit from tracking, consider using TaskCreate to add them. Only use these if relevant to the current work. This is just a gentle reminder - ignore if not applicable.";
26
+
27
+ /**
28
+ * Claude Code 2.1.263+ TaskCreate reminder observed on hitrate S/T4
29
+ * (FREEZE-DIFF + tip0847 pilots). Mentions TaskUpdate and stale-list cleanup.
30
+ */
31
+ const TASKCREATE_NUDGE_CC_2_1_263 =
32
+ "The task tools haven't been used recently. If you're working on tasks that would benefit from tracking progress, consider using TaskCreate to add new tasks and TaskUpdate to update task status (set to in_progress when starting, completed when done). Also consider cleaning up the task list if it has become stale. Only use these if relevant to the current work. This is just a gentle reminder - ignore if not applicable.";
33
+
34
+ interface FenceRange {
35
+ start: number;
36
+ end: number;
37
+ }
38
+
39
+ const FENCE_OPEN_RE = /^( {0,3})(`{3,}|~{3,})/;
40
+ const FENCE_CLOSE_RE = /^( {0,3})(`{3,}|~{3,})[ \t]*$/;
41
+
42
+ /**
43
+ * Markdown fence ranges. An unmatched opener covers through EOF: unfinished
44
+ * fenced examples are code content, not a harness suffix. A closer must be a
45
+ * standalone fence line (no info string) of the same character and at least
46
+ * the opener's length.
47
+ */
48
+ function fencedRanges(source: string): FenceRange[] {
49
+ const ranges: FenceRange[] = [];
50
+ let offset = 0;
51
+ let openAt: number | null = null;
52
+ let openFence = "";
53
+ while (offset <= source.length) {
54
+ const nl = source.indexOf("\n", offset);
55
+ const lineEnd = nl === -1 ? source.length : nl;
56
+ const line = source.slice(offset, lineEnd).replace(/\r$/, "");
57
+ if (openAt === null) {
58
+ const open = FENCE_OPEN_RE.exec(line);
59
+ if (open) {
60
+ openAt = offset;
61
+ openFence = open[2]!;
62
+ }
63
+ } else {
64
+ const close = FENCE_CLOSE_RE.exec(line);
65
+ if (
66
+ close
67
+ && close[2]![0] === openFence[0]
68
+ && close[2]!.length >= openFence.length
69
+ ) {
70
+ ranges.push({ start: openAt, end: lineEnd });
71
+ openAt = null;
72
+ openFence = "";
73
+ }
74
+ }
75
+ if (nl === -1) break;
76
+ offset = nl + 1;
77
+ }
78
+ if (openAt !== null) ranges.push({ start: openAt, end: source.length });
79
+ return ranges;
80
+ }
81
+
82
+ /** A backwards cursor consumes each line and fence range at most once. */
83
+ export function stabilizeClaudeInstructionsForPromptCache(
84
+ instructions: string,
85
+ ): { instructions: string; dynamicNotice: string | null } {
86
+ const ranges = fencedRanges(instructions);
87
+ let fenceIndex = ranges.length - 1;
88
+ let end = instructions.length;
89
+ let latestTotal: string | null = null;
90
+ let latestNudge: string | null = null;
91
+ let peeled = false;
92
+
93
+ for (;;) {
94
+ // This is speculative: a failed candidate must not trim the retained prefix.
95
+ let lineEnd = end;
96
+ while (lineEnd > 0 && instructions[lineEnd - 1] === "\n") {
97
+ lineEnd--;
98
+ if (lineEnd > 0 && instructions[lineEnd - 1] === "\r") lineEnd--;
99
+ }
100
+ if (lineEnd === 0) break;
101
+ const lineStart = instructions.lastIndexOf("\n", lineEnd - 1) + 1;
102
+ let contentStart = lineStart;
103
+ let contentEnd = lineEnd;
104
+ while (contentStart < contentEnd && (instructions[contentStart] === " " || instructions[contentStart] === "\t")) contentStart++;
105
+ while (contentEnd > contentStart && (instructions[contentEnd - 1] === " " || instructions[contentEnd - 1] === "\t")) contentEnd--;
106
+ const notice = instructions.slice(contentStart, contentEnd);
107
+ const total = TOTAL_NOTICE_RE.test(notice);
108
+ const nudge = notice === TASKCREATE_NUDGE_LEGACY || notice === TASKCREATE_NUDGE_CC_2_1_263;
109
+ if (!total && !nudge) break;
110
+
111
+ while (fenceIndex >= 0 && ranges[fenceIndex]!.start > contentStart) fenceIndex--;
112
+ if (fenceIndex >= 0 && contentStart < ranges[fenceIndex]!.end) break;
113
+ if (total && latestTotal === null) latestTotal = notice;
114
+ if (nudge && latestNudge === null) latestNudge = notice;
115
+ peeled = true;
116
+
117
+ // Commit only the recognized notice and its immediately preceding LF/CRLF separators.
118
+ end = lineStart;
119
+ while (end > 0 && instructions[end - 1] === "\n") {
120
+ end--;
121
+ if (end > 0 && instructions[end - 1] === "\r") end--;
122
+ }
123
+ }
124
+
125
+ if (!peeled) return { instructions, dynamicNotice: null };
126
+ const noticeParts: string[] = [];
127
+ if (latestTotal) noticeParts.push(latestTotal);
128
+ if (latestNudge) noticeParts.push(latestNudge);
129
+ return { instructions: instructions.slice(0, end), dynamicNotice: noticeParts.join("\n\n") };
130
+ }