Skip to content
Merged
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
4 changes: 2 additions & 2 deletions docs-site/static/search-index.json

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions docs/api/type-aliases/CatalogErrorRuleJson.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@

---

### patternStatuses?

> `optional` **patternStatuses?**: `number`[]

Restrict the pattern to these HTTP statuses; status matching stays independent.

---

### class

> **class**: [`CatalogErrorRuleClass`](CatalogErrorRuleClass.md)
Expand Down
3 changes: 3 additions & 0 deletions docs/provider-integration/tiers/tier-2-catalog-entry.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ not declared here.
**`errorRules`** — status code and/or case-insensitive pattern → error
class + message. Templates: `{model}`, `{apiKeyEnvVar}`, `{setupUrl}`.
Rules are appended before the defaults and matched first-wins.
`patternStatuses` (optional, with `pattern`) limits the pattern to responses
with one of those HTTP statuses, so the same words on another status fall
through to the defaults.

**`quirks`** — five named, closed escape hatches, all rare. Each one is
consumed generically by `ConfiguredOpenAICompatProvider`/the catalog loader —
Expand Down
10 changes: 9 additions & 1 deletion src/lib/providers/anthropic/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1822,7 +1822,15 @@ export class AnthropicProvider extends BaseProvider {
message: (ctx) => `Connection error: ${ctx.message}`,
},
{
match: (ctx) => /500|502|503|504|server error/i.test(ctx.message),
// Match the shared 5xx predicate: HTTP status or a bounded phrase,
// rather than digits embedded in an unrelated value.
match: (ctx) =>
(ctx.statusCode !== undefined &&
ctx.statusCode >= 500 &&
ctx.statusCode <= 599) ||
/server error|bad gateway|service unavailable|gateway timeout|\berror\b\D{0,12}\b5\d\d\b|\b5\d\d\b\D{0,12}\berror\b|\bstatus(?:\s*code)?\b\D{0,12}\b5\d\d\b/i.test(
ctx.message,
),
errorClass: ProviderError,
message: (ctx) => `Server error: ${ctx.message}`,
},
Expand Down
3 changes: 2 additions & 1 deletion src/lib/providers/catalog/huggingface.json
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,8 @@
"message": "HuggingFace model '{model}' not found.\n\nSuggestions:\n1. Check model name spelling\n2. Ensure an Inference Provider serves the model (https://huggingface.co/inference/models)\n3. For tool calling, use: meta-llama/Llama-3.3-70B-Instruct, zai-org/GLM-5 or Qwen/Qwen3.5-397B-A17B"
},
{
"pattern": "function|tool",
"pattern": "\\b(?:(?:tool|function)s?(?:\\s*/\\s*(?:tool|function)s?)?[ _-]?call(?:s|ing)?|tool_choice|tools? (?:is|are) not supported|(?:does|do) not support tools?)\\b",
"patternStatuses": [400, 422],
"class": "provider",
"message": "HuggingFace tool calling error: {message}\n\nNotes:\n1. Ensure you're using a tool-capable model, e.g. meta-llama/Llama-3.3-70B-Instruct, zai-org/GLM-5 or Qwen/Qwen3.5-397B-A17B\n2. Check that your model supports function calling\n3. Verify tool schema format is correct"
}
Expand Down
6 changes: 5 additions & 1 deletion src/lib/providers/catalog/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,11 @@ function buildErrorRules(entry: ProviderCatalogJson): ProviderErrorRule[] {
return {
match: (ctx) =>
(rule.status !== undefined && ctx.statusCode === rule.status) ||
(regex !== undefined && regex.test(ctx.message)),
(regex !== undefined &&
(rule.patternStatuses === undefined ||
(ctx.statusCode !== undefined &&
rule.patternStatuses.includes(ctx.statusCode))) &&
regex.test(ctx.message)),
errorClass: ERROR_CLASS_MAP[rule.class],
message: needsContext
? (ctx) => interpolate(rule.message, entry, ctx.modelName, ctx.message)
Expand Down
8 changes: 8 additions & 0 deletions src/lib/providers/catalog/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ const catalogErrorRuleJsonSchema = z
.strictObject({
status: z.number().optional(),
pattern: z.string().optional(),
patternStatuses: z.array(z.number().int()).min(1).optional(),
class: catalogErrorRuleClassSchema,
message: z.string(),
})
Expand All @@ -137,6 +138,13 @@ const catalogErrorRuleJsonSchema = z
message: "errorRules entry requires status or pattern",
});
}
if (rule.patternStatuses !== undefined && rule.pattern === undefined) {
ctx.addIssue({
code: "custom",
path: ["patternStatuses"],
message: "errorRules patternStatuses requires pattern",
});
}
if (rule.pattern !== undefined) {
try {
new RegExp(rule.pattern, "i");
Expand Down
42 changes: 9 additions & 33 deletions src/lib/providers/ideogram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ import {
getProviderModel,
validateApiKey,
} from "../utils/providerConfig.js";
import { MAX_IMAGE_BYTES, readBoundedBuffer } from "../utils/sizeGuard.js";
import { assertSafeUrl } from "../utils/ssrfGuard.js";
import { MAX_IMAGE_BYTES } from "../utils/sizeGuard.js";
import { safeDownload } from "../utils/safeFetch.js";
import type { LanguageModel } from "../types/index.js";

const IDEOGRAM_DEFAULT_BASE_URL = "https://api.ideogram.ai";
Expand Down Expand Up @@ -221,37 +221,13 @@ export class IdeogramProvider extends BaseProvider {
throw new Error("Ideogram returned no image URL");
}

// Guard the API-returned URL before fetching (provider-returned URLs
// carry the same SSRF risk as caller-supplied ones) — matches Recraft's
// equivalent download path.
await assertSafeUrl(url);

// Download the image and convert to base64 to match the imageOutput
// contract used by other image-gen providers. Apply a 60s timeout so the
// download cannot hang indefinitely.
const dlController = new AbortController();
const dlTimeoutId = setTimeout(() => dlController.abort(), 60_000);
let dl: Response;
try {
dl = await this.proxyFetch(url, { signal: dlController.signal });
} catch (err: unknown) {
if (err instanceof Error && err.name === "AbortError") {
throw new Error("Ideogram image download timed out after 60s", {
cause: err,
});
}
throw err;
} finally {
clearTimeout(dlTimeoutId);
}
if (!dl.ok) {
throw new Error(`Failed to download Ideogram image: ${dl.status}`);
}
const buffer = await readBoundedBuffer(
dl,
MAX_IMAGE_BYTES,
"Ideogram image",
);
// Resolve and validate once, dial only those addresses, and refuse
// redirects so a changing DNS answer cannot steer this download.
const buffer = await safeDownload(url, {
label: "Ideogram image",
maxBytes: MAX_IMAGE_BYTES,
timeoutMs: 60_000,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const base64 = buffer.toString("base64");

const generationTimeMs = Date.now() - startTime;
Expand Down
4 changes: 3 additions & 1 deletion src/lib/providers/llamaCpp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,9 @@ export class LlamaCppProvider extends OpenAIChatCompletionsProvider {
"Start it with: ./llama-server -m model.gguf --port 8080",
),
{
match: (ctx) => /400/.test(ctx.message),
match: (ctx) =>
ctx.statusCode === 400 ||
(ctx.statusCode === undefined && /\b400\b/.test(ctx.message)),
errorClass: ProviderError,
message:
"llama.cpp rejected the request. Common cause: model doesn't support tools (start llama-server with --jinja for tool support).",
Expand Down
12 changes: 7 additions & 5 deletions src/lib/providers/openAI/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ import { assertSafeUrl } from "../../utils/ssrfGuard.js";
import { createTimeoutController } from "../../utils/timeout.js";
import { stripTrailingSlash } from "../openaiChatCompletionsClient.js";
import { OpenAIChatCompletionsProvider } from "../openaiChatCompletionsBase.js";
import { isOpenAIQuotaExhaustedError } from "../../utils/providerRetry.js";
import {
isOpenAIQuotaExhaustedError,
readOpenAIBodyErrorType,
} from "../../utils/providerRetry.js";

const OPENAI_DEFAULT_BASE_URL = "https://api.openai.com/v1";

Expand Down Expand Up @@ -140,14 +143,13 @@ export class OpenAIProvider extends OpenAIChatCompletionsProvider {
}

public formatProviderError(error: unknown): Error {
// `type` isn't part of `ProviderErrorContext` (it's an OpenAI-specific
// error-body field), so it's read directly off the raw error here and
// captured by the rule closures below.
// Chat errors retain the JSON in responseBody; embedding errors stamp
// type onto the raw error. Keep both paths available to the rule closures.
const errorObj = error as UnknownRecord;
const errorType =
errorObj?.type && typeof errorObj.type === "string"
? errorObj.type
: undefined;
: readOpenAIBodyErrorType(errorObj?.responseBody);

const rules: ProviderErrorRule[] = [
// Curator P1-1 / Reviewer Finding #4: only the explicit auth markers
Expand Down
17 changes: 11 additions & 6 deletions src/lib/providers/openaiChatCompletionsBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1348,6 +1348,7 @@ export abstract class OpenAIChatCompletionsProvider extends BaseProvider {
// endpoint, the one it was written for, is a Tier-2 catalog provider on
// this very base class, so without this the recovery would simply not
// happen for it.
let promptSideFallbackRan = false;
let loop: Awaited<ReturnType<typeof runNativeGenerateLoop>>;
try {
loop = await runLoop(conversation, responseFormat);
Expand All @@ -1364,6 +1365,8 @@ export abstract class OpenAIChatCompletionsProvider extends BaseProvider {
`[${this.providerName}] provider rejected response_format — retrying with the schema in the system prompt`,
{ provider: this.providerName, model: modelId },
);
// Re-asking after this fallback would bill the same request again.
promptSideFallbackRan = true;
loop = await runLoop(
appendJsonSchemaInstruction(conversation, responseFormat.schema),
undefined,
Expand All @@ -1378,6 +1381,7 @@ export abstract class OpenAIChatCompletionsProvider extends BaseProvider {
// case sailed through and handed the caller prose. Same recovery, keyed on
// the result rather than on an exception.
if (
!promptSideFallbackRan &&
responseFormat !== undefined &&
options.schema !== undefined &&
!yieldsSchemaValidObject(loop.text, options.schema as ValidationSchema)
Expand Down Expand Up @@ -2350,19 +2354,20 @@ export abstract class OpenAIChatCompletionsProvider extends BaseProvider {
// `loopPromise` is undefined when a middleware blocked the request
// before `doStream` ran, in which case there is no loop to surface.
await loopPromise;
// Record the terminal finish reason on the metadata object this
// provider returns by reference (`result.metadata`). A rejected
// `loopPromise` throws out of this `await` straight into the `catch`
// below, so reaching this line means `runStreamLoop` returned
// normally and already called `resolveFinish` with the wire value —
// `finishPromise` is settled, not pending.
// Native loops settle the finish promise before returning. A
// middleware can close its synthetic stream without calling doStream,
// leaving no loop to settle it. A promise settles once, so an explicit
// finish part already observed still wins.
//
// `rawFinishReason` is the verbatim vendor value (e.g. "stop",
// "length", "tool_calls", "content_filter") and is accurate the
// moment the wire stream itself finished, independent of whatever
// post-stream work follows. The graded `finishReason` is set later,
// once that post-stream work (the structured-output re-ask below)
// has actually succeeded — see the assignment after that block.
if (!loopPromise) {
resolveFinish("stop");
}
streamMetadata.rawFinishReason = await finishPromise;
// Structured output for a `stream({ schema })` turn. Runs HERE —
// after the stream is fully drained — so a tool-free re-ask (when the
Expand Down
36 changes: 29 additions & 7 deletions src/lib/providers/openaiChatCompletionsClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -544,11 +544,23 @@ const SCHEMA_NODES = [
"else",
] as const;

// OpenAI's strict mode does not accept these composition keywords. A schema
// OpenAI's strict mode does not accept these keywords. A schema
// carrying one cannot be sent with `strict: true` at all, so it is not merely
// "not yet normalised" — it must drop to non-strict, where the schema is
// honoured as written.
const STRICT_UNSUPPORTED = ["allOf", "not", "if", "then", "else"] as const;
const STRICT_UNSUPPORTED = [
"allOf",
"oneOf",
"not",
"if",
"then",
"else",
"dependentRequired",
"dependentSchemas",
"patternProperties",
"uniqueItems",
"prefixItems",
] as const;

const mapValues = (
obj: unknown,
Expand Down Expand Up @@ -600,7 +612,7 @@ const withClosedObjects = (node: unknown): unknown => {
/**
* True when the schema can legally be sent with `strict: true`: every object
* node lists all of its properties as required, every object is closed, and
* no composition keyword OpenAI rejects appears anywhere.
* no unsupported keyword or tuple-valued items appears anywhere.
*
* Deliberately conservative — a false negative costs only the stronger
* guarantee, while a false positive costs the whole request.
Expand All @@ -616,6 +628,10 @@ const satisfiesStrictRequired = (node: unknown): boolean => {
if (STRICT_UNSUPPORTED.some((k) => k in rec)) {
return false;
}
// Draft-07 tuples have an items array; strict mode accepts one items schema.
if (Array.isArray(rec.items)) {
return false;
}
if (rec.type === "object") {
if (rec.additionalProperties !== false) {
return false;
Expand All @@ -641,6 +657,12 @@ const satisfiesStrictRequired = (node: unknown): boolean => {
return mapsOk && nodesOk;
};

const hasObjectRoot = (schema: Record<string, unknown>): boolean =>
schema.type === "object" && !("anyOf" in schema) && !("oneOf" in schema);

const isStrictLegal = (schema: Record<string, unknown>): boolean =>
hasObjectRoot(schema) && satisfiesStrictRequired(schema);

export const v3ResponseFormatToOpenAI = (rf: {
type: "text" | "json";
schema?: Record<string, unknown>;
Expand All @@ -653,7 +675,7 @@ export const v3ResponseFormatToOpenAI = (rf: {
if (!rf.schema) {
return { type: "json_object" };
}
// Mutate as little as possible, in this order:
// Mutate as little as possible (root must be an object, without anyOf/oneOf):
//
// 1. already strict-legal -> send it UNTOUCHED with strict: true
// 2. legal once closed -> send the closed copy with strict: true
Expand All @@ -673,9 +695,9 @@ export const v3ResponseFormatToOpenAI = (rf: {
// what it did before this change.
const original = rf.schema;
const closed = withClosedObjects(original) as Record<string, unknown>;
const schema = satisfiesStrictRequired(original)
const schema = isStrictLegal(original)
? original
: satisfiesStrictRequired(closed)
: isStrictLegal(closed)
? closed
: original;
return {
Expand All @@ -684,7 +706,7 @@ export const v3ResponseFormatToOpenAI = (rf: {
name: rf.name ?? "response",
schema: schema as never,
...(rf.description ? { description: rf.description } : {}),
strict: satisfiesStrictRequired(schema),
strict: isStrictLegal(schema),
},
};
};
Expand Down
39 changes: 9 additions & 30 deletions src/lib/providers/recraft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ import {
getProviderModel,
validateApiKey,
} from "../utils/providerConfig.js";
import { MAX_IMAGE_BYTES, readBoundedBuffer } from "../utils/sizeGuard.js";
import { assertSafeUrl } from "../utils/ssrfGuard.js";
import { MAX_IMAGE_BYTES } from "../utils/sizeGuard.js";
import { safeDownload } from "../utils/safeFetch.js";
import type { LanguageModel } from "../types/index.js";
import { detectImageMimeType } from "../utils/imageDetection.js";

Expand Down Expand Up @@ -227,34 +227,13 @@ export class RecraftProvider extends BaseProvider {
if (entry.b64_json) {
base64 = entry.b64_json;
} else if (entry.url) {
// Guard the API-returned URL before fetching (provider-returned URLs
// carry the same SSRF risk as caller-supplied ones).
await assertSafeUrl(entry.url);
// Fallback URL download — apply a 60s timeout so it cannot hang indefinitely.
const dlController = new AbortController();
const dlTimeoutId = setTimeout(() => dlController.abort(), 60_000);
let dl: Response;
try {
dl = await this.proxyFetch(entry.url, { signal: dlController.signal });
} catch (err: unknown) {
if (err instanceof Error && err.name === "AbortError") {
throw new Error("Recraft image download timed out after 60s", {
cause: err,
});
}
throw err;
} finally {
clearTimeout(dlTimeoutId);
}
if (!dl.ok) {
throw new Error(`Failed to download Recraft image: ${dl.status}`);
}
const dlBuf = await readBoundedBuffer(
dl,
MAX_IMAGE_BYTES,
"Recraft image",
);
base64 = dlBuf.toString("base64");
// See ideogram.ts: pin validated addresses and refuse redirects.
const buffer = await safeDownload(entry.url, {
label: "Recraft image",
maxBytes: MAX_IMAGE_BYTES,
timeoutMs: 60_000,
});
base64 = buffer.toString("base64");
} else {
throw new Error("Recraft response missing both b64_json and url");
}
Expand Down
2 changes: 2 additions & 0 deletions src/lib/types/providerCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ export type CatalogErrorRuleClass =
export type CatalogErrorRuleJson = {
status?: number;
pattern?: string;
/** Restrict the pattern to these HTTP statuses; status matching stays independent. */
patternStatuses?: number[];
class: CatalogErrorRuleClass;
message: string;
};
Expand Down
Loading
Loading