From 60a58a4be45c34def689f76d6f054ea293ea8f0d Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Thu, 17 Sep 2026 17:03:10 +0900 Subject: [PATCH 1/4] fix(devin): forward the selected model's input ceiling Resolve the exact account/model UID's input ceiling and preserve smaller configured hints instead of silently serializing the 128k default. Keep output limits, entitlement preflight and unknown-window fallback unchanged. Add cached-catalog-to-wire regression cases and a scoped review record. Validation: 48 isolated Node/TypeScript checks passed; the original adapter reproduced 128000 against a 262000 expectation. Full Bun tests, repository typecheck and live Devin inference were not run in this environment. --- .../260917_devin_input_ceiling/000_review.md | 85 ++++++++ src/adapters/devin.ts | 56 +++++- tests/providers/devin-prompt-cache.test.ts | 184 +++++++++++++++++- 3 files changed, 321 insertions(+), 4 deletions(-) create mode 100644 devlog/_plan/260917_devin_input_ceiling/000_review.md diff --git a/devlog/_plan/260917_devin_input_ceiling/000_review.md b/devlog/_plan/260917_devin_input_ceiling/000_review.md new file mode 100644 index 00000000000..c910cb63dee --- /dev/null +++ b/devlog/_plan/260917_devin_input_ceiling/000_review.md @@ -0,0 +1,85 @@ +# Devin input-ceiling forwarding review + +## Scope and source baseline + +Reviewed upstream `dev` at `7868f5df570e5f5fb4be3a4b79b7e83526894e5f`. +This is the request-side gap left after merged PR #4323 fixed catalog reporting. + +- `src/adapters/devin/cloud-direct/catalog.ts` reads `ClientModelConfig` field 18 + (`max_input_tokens`) into the exact account/model UID's `contextWindow`. +- `src/adapters/devin/live-models.ts` collapses variants for display, retaining + the smallest reported window across a base family. That display row must not + replace the exact selected UID's request-side evidence. +- `src/adapters/devin.ts` forwarded output, temperature and top-p, but omitted + `completionOpts.maxInputTokens` for every request. +- `src/adapters/devin/cloud-direct/chat.ts` therefore serialized 128000 into + `CompletionConfiguration` field 3, independently of catalog reporting. + Output is field 2; changing its allowance or subtracting it is out of scope. + +The forwarding discrepancy is established. Whether the hosted backend enforces, +ignores or otherwise uses this request field was NOT measured with a live account. +No claim is made that existing sessions were truncated, or that this patch proves +262k/1M end-to-end long-context quality. + +## Implemented boundary + +Resolve a positive safe-integer input ceiling for the actual selected wire UID. +The existing per-account/host catalog cache supplies live evidence. Exact model +hints take precedence over collapsed-base hints, then provider context hints; +a separately configured input-token hint also caps the result. Smaller valid +hints cannot raise the live ceiling. Canonical IDs win over alternative saved +spellings; dotted and case-folded hints remain usable. Another effort or opt-in +long-context variant's live window is never borrowed. + +No live/config evidence leaves the encoder's existing 128k fallback unchanged. +No new model-size table or configuration field is introduced. Provider-wide +catalog/compaction caps retain their existing client-side behavior; this change +forwards the provider/model hints already available to the adapter, not a new +request-body context override. Output, tools, images, reasoning, usage and +entitlement preflight are unchanged. Cancellation is checked after metadata lookup. + +The adapter's metadata read shares the existing cache with selection and chat +preflight. It is not a new inference request. With a warm/successful cache it adds +no catalog HTTP request; on a catalog outage, transport preflight retains its +existing best-effort retry. Cold-failure latency must not be described as identical +to the previous suffix-only path. + +## Regression coverage + +Extend the already registered `tests/providers/devin-prompt-cache.test.ts`. +The new cases seed a synthetic account catalog and stub HTTP, then drive the real +adapter and Connect-RPC serializer. They inspect input field 3, preserve output +field 2 and prompt-cache field 13, and check exact UID, configuration precedence, +missing metadata, discovery failure, cancellation, disabled/unlisted preflight, +and invalid numeric values. No real credentials or billable inference are used. + +## Validation actually performed + +An isolated Node/TypeScript harness executed the full modified adapter with +synthetic catalog/auth/transport boundaries: 48 checks passed, zero failed. +The input/output encoder in that harness mirrors the pinned encoder; it is not +an independently executed full hosted transport. The original adapter reproduced +128000 and failed the new 262000 expectation, while the patched adapter passed. +Both changed TypeScript files transpiled without diagnostics. Original retrieved +files were checked against their Git blob SHAs before editing. + +NOT RUN: repository Bun tests (including the new real-transport regressions), +repository typecheck, changed/full suites, privacy scanner, structure gate, +or real Devin long-context inference. Bun and a full dependency checkout are not +available in this execution environment. These are not green-CI attestations. + +## Review readiness + +Keep the PR draft until the repository checks and necessary structure-owner +cross-links are complete. Run at least: + +```sh +bun test tests/providers/devin-prompt-cache.test.ts tests/providers/devin-adapter.test.ts tests/providers/devin-hardening.test.ts +bun run typecheck +bun run test:changed +bun run privacy:scan +bun run structure:check +``` + +Run the full required suite before marking review-ready. Review cold catalog +failure latency separately from the confirmed request-field fix. diff --git a/src/adapters/devin.ts b/src/adapters/devin.ts index 06fafe3eaef..b62961c7924 100644 --- a/src/adapters/devin.ts +++ b/src/adapters/devin.ts @@ -186,6 +186,46 @@ async function resolveWireModelUid( */ export const resolveWireModelUidForTests = resolveWireModelUid; +/** + * Resolve the INPUT ceiling for the exact UID selected for this turn. Catalog + * ClientModelConfig #18 and CompletionConfiguration #3 both carry input tokens; + * the independent output cap is not subtracted here. Smaller operator hints + * cap live evidence, never enlarge it. No evidence leaves the encoder's 128k + * fallback intact; an unrelated or opt-in long-context variant is not evidence. + */ +function resolveDevinMaxInputTokens( + provider: OcxProviderConfig, + modelUid: string, + liveWindow?: number, +): number | undefined { + const positive = (value: unknown): number | undefined => + typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined; + const baseId = collapseDevinModelUid(modelUid); + const configured = (record: Record | undefined): number | undefined => { + if (!record) return undefined; + for (const id of [modelUid, baseId]) { + // Prefer the canonical spelling; retain dotted/case-folded saved hints, + // matching the model-id normalization used for the inference request. + const exact = Object.hasOwn(record, id) ? positive(record[id]) : undefined; + if (exact !== undefined) return exact; + const matches = Object.entries(record) + .filter(([key]) => normalizeDevinModelId(key).toLowerCase() === id.toLowerCase()) + .map(([, value]) => positive(value)) + .filter((value): value is number => value !== undefined); + if (matches.length > 0) return Math.min(...matches); + } + return undefined; + }; + const contextHint = configured(provider.modelContextWindows) ?? positive(provider.contextWindow); + const inputHint = configured(provider.modelMaxInputTokens); + const ceilings = [positive(liveWindow), contextHint, inputHint] + .filter((value): value is number => value !== undefined); + return ceilings.length > 0 ? Math.min(...ceilings) : undefined; +} + +/** Pure test seam; runtime uses the same resolver immediately before dispatch. */ +export const resolveDevinMaxInputTokensForTests = resolveDevinMaxInputTokens; + export class DevinMissingCredentialError extends Error { constructor() { super("Devin live transport requires a Devin API key. Run ocx login devin to sign in with your Cognition/Devin account."); @@ -506,6 +546,16 @@ export function createDevinAdapter( }; try { + // The same per-account/host cache serves model selection and transport + // preflight. Read the selected UID, not the picker's collapsed base row. + const catalog = await getCachedCatalog(apiKey, host, incoming.abortSignal); + if (incoming.abortSignal?.aborted) { + emit({ type: "error", message: DEVIN_CLIENT_CLOSED_MESSAGE, status: 499, retryable: false }); + return; + } + const maxInputTokens = resolveDevinMaxInputTokens( + provider, modelUid, catalog?.byUid.get(modelUid)?.contextWindow, + ); for await (const event of streamChatEvents({ apiKey, apiServerUrl: host, @@ -513,10 +563,10 @@ export function createDevinAdapter( messages: mapOcxMessagesToDevin(parsed), tools: mapOcxToolsToDevin(parsed.context.tools), cascadeId, - // Without these the request falls back to the encoder's defaults - // (8192 output, a 128k context window, temperature 0.7), so a client - // that asked for a 4k cap never got one. + // Input and output ceilings are separate wire fields. Omitting the + // input hint used to force every model through the 128k default. completionOpts: { + ...(maxInputTokens !== undefined ? { maxInputTokens } : {}), ...(typeof parsed.options.maxOutputTokens === "number" ? { maxOutputTokens: parsed.options.maxOutputTokens } : {}), ...(typeof parsed.options.temperature === "number" ? { temperature: parsed.options.temperature } : {}), ...(typeof parsed.options.topP === "number" ? { topP: parsed.options.topP } : {}), diff --git a/tests/providers/devin-prompt-cache.test.ts b/tests/providers/devin-prompt-cache.test.ts index 9e7dfdd6157..eb1a879b772 100644 --- a/tests/providers/devin-prompt-cache.test.ts +++ b/tests/providers/devin-prompt-cache.test.ts @@ -1,4 +1,13 @@ -import { describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createDevinAdapter, resolveDevinMaxInputTokensForTests } from "../../src/adapters/devin"; +import { parseCatalogBuffer, setCachedCatalogForTests } from "../../src/adapters/devin/cloud-direct/catalog"; +import { encodeMessage, encodeString, encodeVarintField, iterFields } from "../../src/adapters/devin/cloud-direct/wire"; +import { createTranslatorBudget } from "../../src/lib/translator-budget"; +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { buildGetChatMessageRequestForTests, devinCacheIdentity, @@ -84,3 +93,176 @@ describe("session invalidation is scoped to one account", () => { expect(typeof mod.invalidateSessionIdentity).toBe("function"); }); }); + + +describe("catalog-backed input ceilings on the cached chat path", () => { + const apiKey = "ocx-devin-context-fixture"; + const host = "https://server.codeium.com"; + const previousHome = process.env.OPENCODEX_HOME; + const previousJwt = process.env.OPENCODEX_DEVIN_SEND_USER_JWT; + const previousFetch = globalThis.fetch; + let home = ""; + let requests: Buffer[] = []; + let urls: string[] = []; + + function frame(body: Buffer, flags = 0): Buffer { + const header = Buffer.alloc(5); + header[0] = flags; + header.writeUInt32BE(body.length, 1); + return Buffer.concat([header, body]); + } + function fields(buf: Buffer) { + return new Map([...iterFields(buf)].map(field => [field.num, field])); + } + function seed(rows: Array<{ uid: string; window?: number; disabled?: boolean }>): void { + const buffer = Buffer.concat(rows.map(row => encodeMessage(1, Buffer.concat([ + encodeString(1, row.uid), + encodeString(22, row.uid), + ...(row.window === undefined ? [] : [encodeVarintField(18, row.window)]), + encodeVarintField(4, row.disabled ? 1 : 0), + ])))); + setCachedCatalogForTests(parseCatalogBuffer(buffer, apiKey, host)); + } + async function run( + modelId = "swe-2-high", + provider: Partial = {}, + options: OcxParsedRequest["options"] = {}, + signal?: AbortSignal, + ): Promise { + const adapter = createDevinAdapter({ ...provider, adapter: "devin", apiKey, baseUrl: host }); + const events: AdapterEvent[] = []; + await adapter.runTurn!({ + modelId, stream: true, + context: { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, + options: { maxOutputTokens: 64, ...options }, + }, { headers: new Headers(), translatorBudget: createTranslatorBudget(), abortSignal: signal }, + event => { events.push(event); }); + return events; + } + function expectWire(window: number, uid = "swe-2-high"): void { + expect(requests).toHaveLength(1); + const outer = fields(requests[0]!); + const completion = fields(outer.get(8)!.value as Buffer); + expect(completion.get(3)!.value).toBe(BigInt(window)); + expect(completion.get(2)!.value).toBe(64n); + expect((outer.get(21)!.value as Buffer).toString()).toBe(uid); + // A context fix must not remove prompt caching or replace the chosen model. + expect(outer.has(13)).toBe(true); + } + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-devin-context-")); + process.env.OPENCODEX_HOME = home; + delete process.env.OPENCODEX_DEVIN_SEND_USER_JWT; + requests = []; + urls = []; + setCachedCatalogForTests(null); + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + urls.push(url); + if (!url.endsWith("/GetChatMessage")) return new Response("unavailable", { status: 503 }); + const framed = Buffer.from(await (init!.body as Blob).arrayBuffer()); + expect(framed[0]).toBe(0); + expect(framed.readUInt32BE(1)).toBe(framed.length - 5); + requests.push(framed.subarray(5)); + return new Response(Buffer.concat([ + frame(Buffer.concat([encodeString(3, "ok"), encodeVarintField(5, 2)])), + frame(Buffer.from("{}"), 2), + ]), { headers: { "content-type": "application/connect+proto" } }); + }) as typeof fetch; + }); + afterEach(() => { + globalThis.fetch = previousFetch; + setCachedCatalogForTests(null); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (previousJwt === undefined) delete process.env.OPENCODEX_DEVIN_SEND_USER_JWT; + else process.env.OPENCODEX_DEVIN_SEND_USER_JWT = previousJwt; + invalidateSessionIdentity(devinCacheIdentity(apiKey, host)); + removeTreeWithRetry(home); + }); + + test.each([262_000, 1_000_000])("forwards the account's %i input ceiling, not 128k", async window => { + seed([{ uid: "swe-2-high", window }]); + const events = await run(); + expect(events.some(event => event.type === "error")).toBe(false); + expect(events).toContainEqual({ type: "text_delta", text: "ok" }); + expect(events.at(-1)?.type).toBe("done"); + expectWire(window); + expect(urls).toHaveLength(1); // Seeded metadata stays cached through preflight. + }); + + test.each([ + [{ contextWindow: 80_000 }, 80_000], + [{ modelContextWindows: { "swe-2": 90_000 } }, 90_000], + [{ modelContextWindows: { "swe-2-high": 100_000, "swe-2": 180_000 } }, 100_000], + [{ modelContextWindows: { "swe-2": 1_000_000 } }, 262_000], + [{ modelMaxInputTokens: { "swe-2": 70_000 }, contextWindow: 90_000 }, 70_000], + [{ modelContextWindows: { "SWE.2": 110_000 } }, 110_000], + ] as Array<[Partial, number]>)("preserves smaller configured hints %j", async (provider, expected) => { + seed([{ uid: "swe-2-high", window: 262_000 }]); + await run("swe-2-high", provider); + expectWire(expected); + }); + + test.each(["gpt-5-6-sol-high", "gpt-5-6-sol-high-1m"])("uses exact variant evidence for %s", async uid => { + seed([ + { uid: "gpt-5-6-sol-high", window: 200_000 }, + { uid: "gpt-5-6-sol-high-1m", window: 1_000_000 }, + ]); + await run(uid); + expectWire(uid.endsWith("-1m") ? 1_000_000 : 200_000, uid); + }); + + test("looks up the final effort UID rather than the originally requested variant", async () => { + seed([{ uid: "swe-2-medium", window: 240_000 }, { uid: "swe-2-high", window: 262_000 }]); + await run("devin/swe-2-high", {}, { reasoning: "medium" }); + expectWire(240_000, "swe-2-medium"); + }); + + test.each([undefined, 0])("keeps 128k when the exact row has no positive window (%p)", async window => { + seed([{ uid: "swe-2-high", window }, { uid: "swe-2-max", window: 1_000_000 }]); + await run(); + expectWire(128_000); + }); + + test("falls back to the operator's input hint when discovery is unavailable", async () => { + await run("swe-2-high", { modelMaxInputTokens: { "swe-2": 60_000 } }); + expectWire(60_000); + expect(urls.filter(url => url.endsWith("/GetChatMessage"))).toHaveLength(1); + }); + + test("keeps the encoder default without discovery or a configured hint", async () => { + await run(); + expectWire(128_000); + }); + + test.each([true, false])("retains disabled/unlisted preflight rejection (%p)", async disabled => { + seed([{ uid: disabled ? "swe-2-high" : "other-high", window: 262_000, disabled }]); + const events = await run(); + expect(events.some(event => event.type === "error")).toBe(true); + expect(requests).toHaveLength(0); + }); + + test("an already cancelled turn never fetches metadata or sends inference", async () => { + const controller = new AbortController(); + controller.abort(); + const events = await run("swe-2-high", {}, {}, controller.signal); + expect(events).toContainEqual({ type: "error", message: "client closed request", status: 499, retryable: false }); + expect(urls).toHaveLength(0); + }); +}); + +describe("Devin input ceiling validation", () => { + test.each([NaN, Infinity, -Infinity, 0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1])("ignores invalid numeric metadata %p", invalid => { + const provider = { adapter: "devin", contextWindow: invalid, modelMaxInputTokens: { "swe-2": invalid } }; + expect(resolveDevinMaxInputTokensForTests(provider, "swe-2-high", invalid)).toBeUndefined(); + expect(resolveDevinMaxInputTokensForTests(provider, "swe-2-high", 262_000)).toBe(262_000); + }); + + test("does not borrow another variant's input limit", () => { + expect(resolveDevinMaxInputTokensForTests({ + adapter: "devin", modelContextWindows: { "swe-2-max": 1_000_000 }, + }, "swe-2-high")).toBeUndefined(); + }); +}); From 6b6f1becd5e93ca1173be7af1104cac47e2a3781 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Thu, 17 Sep 2026 20:26:39 +0900 Subject: [PATCH 2/4] docs(structure): cross-link the Devin input-ceiling review in the adapter owner --- structure/adapters/registry.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index 1eaa48e016c..e67c29c038f 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -64,6 +64,14 @@ Some adapters share another adapter's routed-tool semantics while retaining inde like `-1m` included: unmeasured rows abstain, unanimous measured rows advertise `["text"]` or `["text", "image"]`, and measured disagreement stays unadvertised. + At dispatch the adapter reads the same per-account/host cache once more for the + exact selected wire UID and forwards `completionOpts.maxInputTokens`: the smallest + of that row's field #18 window and any valid configured model/provider input + hints, so `CompletionConfiguration` field #3 no longer serializes the encoder's + 128000 fallback. Smaller operator hints cap live evidence and never enlarge it; + with no evidence the field stays omitted. Investigation and limits: + `devlog/_plan/260917_devin_input_ceiling/000_review.md`. + The registry records those relationships with `contractParent`. A parent relationship does **not** mean the registry recursively constructs a parent adapter and injects it into the child. Azure and MiMo keep owning their existing internal composition. This avoids making production constructors depend on test/conformance needs and keeps this authority refactor behavior-neutral. Codex Spark retirement removes model-specific exceptions from the Responses adapter, without From 5dba9326cdf3004ff25dbe88caa930d4a56c1f08 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Fri, 18 Sep 2026 00:29:34 +0900 Subject: [PATCH 3/4] fix(devin): reuse the turn's single catalog read in the chat preflight getCachedCatalog returns null without caching when fetchCatalog fails, so each same-turn caller retried the request: resolveWireModelUid, the runTurn preflight read, and the streamChatEvents preflight could each pay the catalog fetch timeout before a valid turn started. runTurn now performs the one catalog read and threads the result through resolveWireModelUid (new optional parameter; the test seam still falls back to its own lookup) and streamChatEvents (new optional CloudChatRequest.catalog field). An explicit null is passed through deliberately so a failed lookup is not retried inside the turn; the null fallback and the 499 abort handling are unchanged, and cancellation during the read is now abort-responsive. Regression: devin-prompt-cache asserts exactly one metadata request per turn when the catalog endpoint fails. --- src/adapters/devin.ts | 38 +++++++++++++--------- src/adapters/devin/cloud-direct/chat.ts | 13 ++++++-- tests/providers/devin-prompt-cache.test.ts | 11 +++++++ 3 files changed, 45 insertions(+), 17 deletions(-) diff --git a/src/adapters/devin.ts b/src/adapters/devin.ts index b62961c7924..9b3a971fa77 100644 --- a/src/adapters/devin.ts +++ b/src/adapters/devin.ts @@ -11,7 +11,7 @@ import { namespacedToolName } from "../types"; import type { IncomingMeta, ProviderAdapter } from "./base"; import { streamChatEvents, allocateCascadeId, CloudChatError, type ChatHistoryItem, type ToolDef } from "./devin/cloud-direct"; import type { ContentPart } from "./devin/cloud-direct/chat"; -import { getCachedCatalog } from "./devin/cloud-direct/catalog"; +import { getCachedCatalog, type CacheEntry } from "./devin/cloud-direct/catalog"; import { collapseDevinModelUid } from "./devin/live-models"; import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge"; import { DEVIN_DEFAULT_API_SERVER, resolveDevinApiServer } from "../oauth/devin"; @@ -155,6 +155,7 @@ async function resolveWireModelUid( apiKey: string, host: string, reasoningEffort?: string, + catalog?: CacheEntry | null, ): Promise { const modelId = normalizeDevinModelId(rawModelId); // Explicit effort wins over a suffix the picker already baked into the id, so @@ -163,15 +164,18 @@ async function resolveWireModelUid( const swe2 = resolveSwe2Variant(modelId, reasoningEffort); if (swe2) return swe2; if (hasEffortSuffix(modelId)) return modelId; - const catalog = await getCachedCatalog(apiKey, host); - if (catalog) { - if (catalog.byUid.has(modelId)) return modelId; + // Callers that already read the catalog this turn pass it in; an explicit + // null records a failed lookup and must not trigger a same-turn retry — + // failures are not cached, so re-reading would only pay another timeout. + const entry = catalog !== undefined ? catalog : await getCachedCatalog(apiKey, host); + if (entry) { + if (entry.byUid.has(modelId)) return modelId; const effort = reasoningEffort && CALLER_EFFORT_VALUES.has(reasoningEffort) ? reasoningEffort : "medium"; const suffixed = `${modelId}-${effort}`; - if (catalog.byUid.has(suffixed)) return suffixed; + if (entry.byUid.has(suffixed)) return suffixed; // Fall back to any enabled variant of this base model. - for (const uid of catalog.byUid.keys()) { - if (uid.startsWith(modelId + "-") && !catalog.byUid.get(uid)?.disabled) return uid; + for (const uid of entry.byUid.keys()) { + if (uid.startsWith(modelId + "-") && !entry.byUid.get(uid)?.disabled) return uid; } } // Degraded mode: append the default effort suffix. @@ -533,7 +537,16 @@ export function createDevinAdapter( // entry: an EU or FedStart account that used provider.baseUrl would send // every RPC to the US server it is not provisioned on. const host = resolveDevinApiServer(provider.baseUrl, credentialProviderId); - const modelUid = await resolveWireModelUid(rawModelId, apiKey, host, parsed.options.reasoning); + // One catalog read per turn serves model-UID resolution, the input + // ceiling, and the chat pre-flight inside streamChatEvents. Failures are + // not cached, so a second read would only pay another fetch timeout on + // an otherwise valid turn. + const catalog = await getCachedCatalog(apiKey, host, incoming.abortSignal); + if (incoming.abortSignal?.aborted) { + emit({ type: "error", message: DEVIN_CLIENT_CLOSED_MESSAGE, status: 499, retryable: false }); + return; + } + const modelUid = await resolveWireModelUid(rawModelId, apiKey, host, parsed.options.reasoning, catalog); const returnedToolNames = buildDevinReturnedToolNameMap(parsed.context.tools); let openToolId: string | undefined; let usage: OcxUsage | undefined; @@ -546,13 +559,7 @@ export function createDevinAdapter( }; try { - // The same per-account/host cache serves model selection and transport - // preflight. Read the selected UID, not the picker's collapsed base row. - const catalog = await getCachedCatalog(apiKey, host, incoming.abortSignal); - if (incoming.abortSignal?.aborted) { - emit({ type: "error", message: DEVIN_CLIENT_CLOSED_MESSAGE, status: 499, retryable: false }); - return; - } + // Read the selected UID's catalog row, not the picker's collapsed base. const maxInputTokens = resolveDevinMaxInputTokens( provider, modelUid, catalog?.byUid.get(modelUid)?.contextWindow, ); @@ -560,6 +567,7 @@ export function createDevinAdapter( apiKey, apiServerUrl: host, modelUid, + catalog, messages: mapOcxMessagesToDevin(parsed), tools: mapOcxToolsToDevin(parsed.context.tools), cascadeId, diff --git a/src/adapters/devin/cloud-direct/chat.ts b/src/adapters/devin/cloud-direct/chat.ts index 294d6ea8433..794c5cddf22 100644 --- a/src/adapters/devin/cloud-direct/chat.ts +++ b/src/adapters/devin/cloud-direct/chat.ts @@ -35,7 +35,7 @@ import { } from './wire.js'; import { buildMetadata } from './metadata.js'; import { getCachedUserJwt } from './auth.js'; -import { getCachedCatalog, ModelNotAvailableError } from './catalog.js'; +import { getCachedCatalog, ModelNotAvailableError, type CacheEntry } from './catalog.js'; import { anySignal, cancelBodyOnAbort } from '../../../lib/abort.js'; import { resolveDevinApiBaseUrl } from '../../../oauth/devin/api-base.js'; @@ -1046,6 +1046,13 @@ export interface CloudChatRequest { completionOpts?: BuildArgs['completionOpts']; /** Override request_type (default = 5, CASCADE). */ requestType?: number; + /** + * Catalog the caller already resolved this turn. An explicit `null` + * records a failed lookup: the pre-flight below then skips its own fetch + * instead of paying a second catalog timeout on the same turn. Omit the + * field to let the pre-flight perform its own cached lookup. + */ + catalog?: CacheEntry | null; /** Abort signal — closes the fetch stream. */ signal?: AbortSignal; } @@ -1140,7 +1147,9 @@ export async function* streamChatEvents(req: CloudChatRequest): AsyncGenerator null); + const catalog = req.catalog !== undefined + ? req.catalog + : await getCachedCatalog(req.apiKey, host, req.signal).catch(() => null); if (catalog && catalog.byUid.size > 0) { const entry = catalog.byUid.get(req.modelUid); if (!entry) { diff --git a/tests/providers/devin-prompt-cache.test.ts b/tests/providers/devin-prompt-cache.test.ts index eb1a879b772..362732709ce 100644 --- a/tests/providers/devin-prompt-cache.test.ts +++ b/tests/providers/devin-prompt-cache.test.ts @@ -232,6 +232,17 @@ describe("catalog-backed input ceilings on the cached chat path", () => { expect(urls.filter(url => url.endsWith("/GetChatMessage"))).toHaveLength(1); }); + test("a failed catalog lookup is not retried within the turn", async () => { + // No seed: the mocked endpoint 503s, so every uncached catalog read issues + // a fetch (the user_jwt mint runs first and fails). The turn must make + // exactly one metadata attempt - runTurn hands the result to UID + // resolution and to the chat pre-flight. + const events = await run("gpt-5-6-sol"); + expect(events).toContainEqual({ type: "text_delta", text: "ok" }); + expect(urls.filter(url => !url.endsWith("/GetChatMessage"))).toHaveLength(1); + expect(urls.filter(url => url.endsWith("/GetChatMessage"))).toHaveLength(1); + }); + test("keeps the encoder default without discovery or a configured hint", async () => { await run(); expectWire(128_000); From 795a416b5bcb2a360c045d8005244835a33368b3 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Fri, 18 Sep 2026 02:48:54 +0900 Subject: [PATCH 4/4] docs(structure): clarify the no-evidence wire-field fallback --- structure/adapters/registry.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index 5e76d14a1ae..0607b325c9b 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -73,7 +73,8 @@ Some adapters share another adapter's routed-tool semantics while retaining inde of that row's field #18 window and any valid configured model/provider input hints, so `CompletionConfiguration` field #3 no longer serializes the encoder's 128000 fallback. Smaller operator hints cap live evidence and never enlarge it; - with no evidence the field stays omitted. Investigation and limits: + with no evidence the adapter hint is omitted and the encoder still serializes + its own 128000 fallback for field #3. Investigation and limits: `devlog/_plan/260917_devin_input_ceiling/000_review.md`. The registry records those relationships with `contractParent`. A parent relationship does **not** mean the registry recursively constructs a parent adapter and injects it into the child. Azure and MiMo keep owning their existing internal composition. This avoids making production constructors depend on test/conformance needs and keeps this authority refactor behavior-neutral.