Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs-site/src/content/docs/reference/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,16 @@ configuration that names the old id is rewritten at startup.
`CompletionConfiguration`, #2 is the output cap and #3 is the context window; swapping those two
makes every turn fail with an opaque `invalid_argument`. A temperature of exactly 0 is refused, so
it is clamped to the smallest accepted value.
- A pre-output 429 that states a recovery delay is retried in place only when the full stated
delay fits within the remaining cumulative wait allowance. The adapter waits that full delay
and replays the request up to twice; the default cumulative allowance is 30 minutes
(`OPENCODEX_DEVIN_STATED_RESET_WAIT_MS`, hard ceiling one hour). If the delay exceeds the
remaining allowance, the original 429 is surfaced without waiting or replaying. Retrying
earlier than the stated delay is deliberately not attempted — the hint is the provider's best
estimate of its own window, and each replay slot is finite. If the limit still refuses, the
final 429 surfaces to the client with the stated delay preserved as its cooldown hint. A `~`
in the surfaced message marks a delay recovered from a secondhand trailer sentence rather
than an exact header value; clients still receive the parsed number itself.
- Experimental unofficial bridge; not shown in the dashboard preset by default. See the
[provider guide](/guides/providers/) for login instructions.

Expand Down
4 changes: 3 additions & 1 deletion src/lib/retry-delay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const MAX_COMPONENTS = 16;

function durationSeconds(tail: string, allowBareSeconds: boolean): number | undefined {
let rest = tail.trimStart();
if (allowBareSeconds && rest.startsWith("~")) rest = rest.slice(1).trimStart();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
let seconds = 0;
let components = 0;
while (true) {
Expand Down Expand Up @@ -50,7 +51,8 @@ function durationSeconds(tail: string, allowBareSeconds: boolean): number | unde

/**
* Supports reset(s) in, try again in and Retry-After/retry after hints; accepts
* compound durations and rounds UP once after summing all components.
* compound durations, the generated Retry-After approximation marker, and
* rounds UP once after summing all components.
* A bare number is permitted only for header-style Retry-After hints, never
* for "reset in 2026". When a message declares several usable lower bounds,
* honour the longest one rather than re-entering a still-live quota window.
Expand Down
6 changes: 5 additions & 1 deletion structure/adapters/registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,11 @@ Some adapters share another adapter's routed-tool semantics while retaining inde
with no evidence the adapter hint is omitted and the encoder still serializes
its own 128000 fallback for field #3. Connect trailer diagnostics expose only an
allowlisted error code, hexadecimal trace id and typed `retryAfterSeconds`, optionally rendered as
generated `retry after ~Ns` wording; raw text stays internal because it can reflect credentials. Investigation and limits:
generated `retry after ~Ns` wording; the shared retry-delay parser accepts that generated
approximation marker and preserves the same lower-bound delay when the diagnostic returns as an
outer error message. Each bounded replay evaluates its own typed delay or compatible message, so
a later refusal may change between the raw reset sentence and the generated diagnostic without
losing the next wait. Raw text stays internal because it can reflect credentials. 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.
Expand Down
38 changes: 38 additions & 0 deletions tests/providers/devin-stated-reset-retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,44 @@ describe("streamChatEventsWithResetRetry", () => {
expect(out.map(e => e.kind)).toEqual(["text", "finish"]);
});

test("waits the generated approximate retry delay and replays", async () => {
const waits: number[] = [];
let calls = 0;
const stream = () => {
calls += 1;
return calls === 1
? exhausting("Cognition chat failed (resource_exhausted); retry after ~180s")()
: events({ kind: "finish", reason: "stop" } as CloudChatEvent);
};
const out = await drain(streamChatEventsWithResetRetry(REQ, {
stream,
sleep: async (ms) => { waits.push(ms); },
}));
expect(calls).toBe(2);
expect(waits).toEqual([180_000]);
expect(out.map(e => e.kind)).toEqual(["finish"]);
});

test("re-evaluates the delay when retry failures use different wording", async () => {
const waits: number[] = [];
let calls = 0;
const stream = () => {
calls += 1;
if (calls === 1) return exhausting("Your limit will reset in 35 seconds")();
if (calls === 2) {
return exhausting("Cognition chat failed (resource_exhausted); retry after ~180s")();
}
return events({ kind: "finish", reason: "stop" } as CloudChatEvent);
};
const out = await drain(streamChatEventsWithResetRetry(REQ, {
stream,
sleep: async (ms) => { waits.push(ms); },
}));
expect(calls).toBe(3);
expect(waits).toEqual([35_000, 180_000]);
expect(out.map(e => e.kind)).toEqual(["finish"]);
});

test("does not replay once any event was yielded", async () => {
const stream = () => (async function* (): AsyncGenerator<CloudChatEvent> {
yield { kind: "text", text: "partial" } as CloudChatEvent;
Expand Down
8 changes: 8 additions & 0 deletions tests/server/retry-after-429.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,14 @@ describe("resolveClientRetryAfter (#507)", () => {
})).toBe("35");
});

test("keeps a generated approximate Cognition delay in client cooldown metadata", () => {
expect(resolveClientRetryAfter({
status: 429,
message: "Cognition chat failed (resource_exhausted); retry after ~180s",
includeDefault: false,
})).toBe("180");
});

test("reads a stated reset in minutes and hours, not just seconds", () => {
expect(resolveClientRetryAfter({
status: 429,
Expand Down
3 changes: 3 additions & 0 deletions tests/server/retry-delay-hardening.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ describe("stated reset duration boundaries", () => {
["retry after 1500 milliseconds", 2],
["reset in 1 minute 500 milliseconds", 61],
["retry after 7.2s", 8],
["retry after ~180s", 180],
["Retry-After: ~3 minutes", 180],
["Retry-After: 30", 30],
["Retry-After: 0.1", 1],
["Your limit RESETS IN 21 MINUTES", 1260],
Expand All @@ -33,6 +35,7 @@ describe("stated reset duration boundaries", () => {
"reset in 5 minutes 30",
"reset in 5 months",
"retry after 3 monkeys",
"reset in ~3 minutes",
"try again in 1e3s",
"Retry-After: 3:30",
"Retry-After: 123abc",
Expand Down
Loading