Q87: provider errors keep status and provider code; text never decides recovery
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7ecffe84e3
commit
4668396f25
28 files changed
+285
-108
No files matched your search
@@ -8,6 +8,7 @@
|
||||
import { startOAuthCallbackServer } from "./oauth-callback-server";
|
||||
import { generatePkce } from "./pkce";
|
||||
import type { OAuthCredentials, OAuthFlow, OAuthLoginCallbacks } from "./oauth-types";
|
||||
import { httpFailure } from "../provider-http";
|
||||
|
||||
const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
||||
const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
|
||||
@@ -61,7 +62,7 @@ export async function refreshClaudePro(refreshToken: string): Promise<OAuthCrede
|
||||
body: JSON.stringify({ grant_type: "refresh_token", client_id: CLIENT_ID, refresh_token: refreshToken }),
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) throw new Error(`Claude token refresh failed: ${response.status} ${text}`);
|
||||
if (!response.ok) throw httpFailure(`Claude token refresh failed: ${response.status} ${text}`, response, text);
|
||||
const json = JSON.parse(text) as { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown };
|
||||
if (typeof json.access_token !== "string" || typeof json.refresh_token !== "string" || typeof json.expires_in !== "number") {
|
||||
throw new Error("Claude token refresh response was missing required fields.");
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
import { listProviderModels } from "../../../shared/provider-models";
|
||||
import type { OAuthCredentials, OAuthFlow, OAuthLoginCallbacks } from "./oauth-types";
|
||||
import { httpFailure } from "../provider-http";
|
||||
|
||||
// Decoded from the published GitHub Copilot client id used by the official CLI.
|
||||
const CLIENT_ID = "Iv1.b507a08c87ecfe98";
|
||||
@@ -47,7 +48,7 @@ async function fetchJson(url: string, init: RequestInit): Promise<Record<string,
|
||||
const response = await fetch(url, init);
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
throw new Error(`${response.status} ${response.statusText}: ${text}`);
|
||||
throw httpFailure(`${response.status} ${response.statusText}: ${text}`, response, text);
|
||||
}
|
||||
return (await response.json()) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { startOAuthCallbackServer } from "./oauth-callback-server";
|
||||
import { generatePkce } from "./pkce";
|
||||
import type { OAuthCredentials, OAuthFlow, OAuthLoginCallbacks } from "./oauth-types";
|
||||
import { httpFailure } from "../provider-http";
|
||||
|
||||
const CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com";
|
||||
const CALLBACK_PORT = 51121;
|
||||
@@ -115,7 +116,7 @@ export async function refreshAntigravity(refreshToken: string, projectId: string
|
||||
}),
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) throw new Error(`Antigravity token refresh failed: ${response.status} ${text}`);
|
||||
if (!response.ok) throw httpFailure(`Antigravity token refresh failed: ${response.status} ${text}`, response, text);
|
||||
const json = JSON.parse(text) as { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown };
|
||||
if (typeof json.access_token !== "string" || typeof json.expires_in !== "number") {
|
||||
throw new Error("Antigravity token refresh response was missing required fields.");
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { hostname, platform as osPlatform, release as osRelease, userInfo } from "node:os";
|
||||
import type { OAuthCredentials, OAuthFlow, OAuthLoginCallbacks } from "./oauth-types";
|
||||
import { httpFailure } from "../provider-http";
|
||||
|
||||
// Published public client id of the official Kimi CLI; reused by third-party
|
||||
// coding agents per Kimi's device-flow integration guidance.
|
||||
@@ -47,7 +48,7 @@ function deviceHeaders(): Record<string, string> {
|
||||
};
|
||||
}
|
||||
|
||||
async function postForm(path: string, form: Record<string, string>): Promise<{ status: number; data: Record<string, unknown> }> {
|
||||
async function postForm(path: string, form: Record<string, string>): Promise<{ status: number; data: Record<string, unknown>; response: Response; text: string }> {
|
||||
const response = await fetch(`${OAUTH_HOST}${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -62,12 +63,12 @@ async function postForm(path: string, form: Record<string, string>): Promise<{ s
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error(`Kimi OAuth endpoint returned a non-JSON response: ${response.status} ${text.slice(0, 200)}`);
|
||||
throw httpFailure(`Kimi OAuth endpoint returned a non-JSON response: ${response.status} ${text.slice(0, 200)}`, response, text);
|
||||
}
|
||||
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
||||
throw new Error(`Kimi OAuth endpoint returned an unexpected payload: ${response.status}`);
|
||||
throw httpFailure(`Kimi OAuth endpoint returned an unexpected payload: ${response.status}`, response, text);
|
||||
}
|
||||
return { status: response.status, data: data as Record<string, unknown> };
|
||||
return { status: response.status, data: data as Record<string, unknown>, response, text };
|
||||
}
|
||||
|
||||
interface DeviceAuthorization {
|
||||
@@ -167,12 +168,12 @@ async function pollForToken(device: DeviceAuthorization, signal?: AbortSignal):
|
||||
|
||||
/** Refresh an expired Kimi Code access token (they live ~15 minutes). */
|
||||
export async function refreshKimiCode(refreshToken: string): Promise<OAuthCredentials> {
|
||||
const { status, data } = await postForm("/api/oauth/token", {
|
||||
const { status, data, response, text } = await postForm("/api/oauth/token", {
|
||||
client_id: CLIENT_ID,
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
if (status !== 200) throw new Error(`Kimi token refresh failed: ${status} ${JSON.stringify(data)}`);
|
||||
if (status !== 200) throw httpFailure(`Kimi token refresh failed: ${status} ${JSON.stringify(data)}`, response, text);
|
||||
return credentialsFromToken(data);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { startOAuthCallbackServer } from "./oauth-callback-server";
|
||||
import { generatePkce } from "./pkce";
|
||||
import type { OAuthCredentials, OAuthFlow, OAuthLoginCallbacks } from "./oauth-types";
|
||||
import { httpFailure } from "../provider-http";
|
||||
|
||||
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
||||
const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
|
||||
@@ -84,7 +85,7 @@ export async function refreshCodex(refreshToken: string): Promise<OAuthCredentia
|
||||
body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: CLIENT_ID }),
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) throw new Error(`Codex token refresh failed: ${response.status} ${text}`);
|
||||
if (!response.ok) throw httpFailure(`Codex token refresh failed: ${response.status} ${text}`, response, text);
|
||||
const json = JSON.parse(text) as { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown };
|
||||
if (typeof json.access_token !== "string" || typeof json.refresh_token !== "string" || typeof json.expires_in !== "number") {
|
||||
throw new Error("Codex token refresh response was missing required fields.");
|
||||
|
||||
@@ -20,6 +20,7 @@ import { ProviderRequestStore, type ProviderBackgroundRequestRow } from "./provi
|
||||
import { ProviderResponseObserver } from "./provider-response-observer";
|
||||
import { hashPrompt, readProviderFailureMessage, requireBackgroundText } from "./provider-response-utils";
|
||||
import { promptChars } from "./spend";
|
||||
import { isRecord, ProviderHttpError, readProviderErrorCode } from "./provider-http";
|
||||
|
||||
const AUTH_STATE_CONFIGURED = "configured";
|
||||
const HEALTH_STATE_HEALTHY = "healthy";
|
||||
@@ -190,7 +191,7 @@ export class ProviderBackgroundService {
|
||||
return;
|
||||
}
|
||||
if (FAILED_BACKGROUND_STATUSES.has(result.status)) {
|
||||
throw new ProviderBackgroundStatusError(readProviderFailureMessage(result.rawResponseJson, result.status), result.rawResponseJson);
|
||||
throw new ProviderBackgroundStatusError(readProviderFailureMessage(result.rawResponseJson, result.status), result.rawResponseJson, readBackgroundFailureCode(result.rawResponseJson));
|
||||
}
|
||||
throw new Error(`Provider background response returned unsupported status ${result.status}.`);
|
||||
} catch (error) {
|
||||
@@ -201,7 +202,8 @@ export class ProviderBackgroundService {
|
||||
const failures = this.requestStore.recordPollFailure(request.id, message);
|
||||
console.warn(`[provider-background] status check ${failures}/${MAX_POLL_FAILURES} for request ${request.id} failed: ${message}`);
|
||||
if (failures < MAX_POLL_FAILURES) return;
|
||||
finalError = new Error(`Gave up after ${MAX_POLL_FAILURES} failed status checks: ${message}`);
|
||||
const gaveUp = `Gave up after ${MAX_POLL_FAILURES} failed status checks: ${message}`;
|
||||
finalError = error instanceof ProviderHttpError ? new ProviderHttpError(gaveUp, error.rateLimitState, error.status, error.timedOut, error.code) : new Error(gaveUp);
|
||||
}
|
||||
this.responseObserver.recordFailure({
|
||||
orgId: request.org_id,
|
||||
@@ -347,9 +349,17 @@ interface ProviderBackgroundRequestProvider {
|
||||
model: string;
|
||||
}
|
||||
|
||||
class ProviderBackgroundStatusError extends Error {
|
||||
constructor(message: string, readonly rawResponseJson: string) {
|
||||
super(message);
|
||||
/** The provider reported the background job failed or incomplete; its code is the error's code or the incomplete reason. */
|
||||
class ProviderBackgroundStatusError extends ProviderHttpError {
|
||||
constructor(message: string, readonly rawResponseJson: string, code: string | null) {
|
||||
super(message, null, null, false, code);
|
||||
this.name = "ProviderBackgroundStatusError";
|
||||
}
|
||||
}
|
||||
|
||||
function readBackgroundFailureCode(rawResponseJson: string): string | null {
|
||||
const parsed = JSON.parse(rawResponseJson) as unknown;
|
||||
if (!isRecord(parsed)) return null;
|
||||
const incomplete = parsed.incomplete_details;
|
||||
return readProviderErrorCode(parsed) ?? (isRecord(incomplete) && typeof incomplete.reason === "string" ? incomplete.reason : null);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { isTransientProviderFailure } from "../../shared/provider-runtime-domain";
|
||||
import { ProviderHttpError } from "./provider-http";
|
||||
|
||||
/** Whether a failed call is the provider's passing trouble, and how long it asked us to wait. */
|
||||
@@ -15,17 +14,29 @@ const TRANSIENT_CODES = new Set([
|
||||
"UND_ERR_SOCKET", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT",
|
||||
]);
|
||||
|
||||
/** Classifies by status, timeout and network error code first; the message text is the last resort. */
|
||||
/** Provider error codes for rate limits, used-up usage windows, overload and server trouble. */
|
||||
const TRANSIENT_PROVIDER_CODES = new Set([
|
||||
"rate_limit_exceeded", "rate_limit_error", "usage_limit_reached", "server_error", "api_error", "overloaded_error",
|
||||
"RESOURCE_EXHAUSTED", "UNAVAILABLE", "INTERNAL", "DEADLINE_EXCEEDED",
|
||||
"ThrottlingException", "ServiceUnavailableException", "InternalServerException", "ModelNotReadyException",
|
||||
]);
|
||||
|
||||
/** Our own refusal when a provider's monthly request allowance is used up; it lifts when the month turns. */
|
||||
export class ProviderLimitError extends Error {
|
||||
readonly name = "ProviderLimitError";
|
||||
}
|
||||
|
||||
/** Classifies by status, provider code, timeout and network code; the message text never decides. */
|
||||
export function classifyProviderFailure(error: unknown, now: number): ProviderFailure {
|
||||
if (error instanceof ProviderLimitError) return { transient: true, retryAfterMs: null };
|
||||
if (error instanceof ProviderHttpError) {
|
||||
const state = error.rateLimitState;
|
||||
const resetIn = state?.resetAt ? Date.parse(state.resetAt) - now : null;
|
||||
const retryAfterMs = state?.retryAfterMs ?? (resetIn !== null && resetIn > 0 ? resetIn : null);
|
||||
if (error.timedOut || (error.status !== null && (TRANSIENT_STATUSES.has(error.status) || (error.status >= 500 && error.status < 600)))) return { transient: true, retryAfterMs };
|
||||
if (error.status !== null) return { transient: isTransientProviderFailure(error.message), retryAfterMs };
|
||||
const transientStatus = error.status !== null && (TRANSIENT_STATUSES.has(error.status) || (error.status >= 500 && error.status < 600));
|
||||
return { transient: error.timedOut || transientStatus || TRANSIENT_PROVIDER_CODES.has(error.code ?? ""), retryAfterMs };
|
||||
}
|
||||
if (TRANSIENT_CODES.has(errorCode(error) ?? "")) return { transient: true, retryAfterMs: null };
|
||||
return { transient: isTransientProviderFailure(error instanceof Error ? error.message : String(error)), retryAfterMs: null };
|
||||
return { transient: TRANSIENT_CODES.has(errorCode(error) ?? ""), retryAfterMs: null };
|
||||
}
|
||||
|
||||
function errorCode(error: unknown): string | null {
|
||||
|
||||
@@ -40,6 +40,8 @@ export class ProviderHttpError extends Error {
|
||||
/** HTTP status of the failed response; null when no response arrived. */
|
||||
readonly status: number | null = null,
|
||||
readonly timedOut = false,
|
||||
/** The provider's machine-readable error code (OpenAI code/type, Anthropic type, Google status, AWS error type). */
|
||||
readonly code: string | null = null,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ProviderHttpError";
|
||||
@@ -92,7 +94,7 @@ export async function postEventStream(
|
||||
const raw = await withDeadline(controller, timeouts.bodyTimeoutMs ?? BODY_TIMEOUT_MS, () => response.text());
|
||||
const data = parseProviderJson(raw, false);
|
||||
const message = readProviderError(data) ?? (raw.trim() || `Provider request failed with HTTP ${response.status}.`);
|
||||
throw new ProviderHttpError(message, rateLimitState, response.status);
|
||||
throw new ProviderHttpError(message, rateLimitState, response.status, false, readProviderErrorCode(data, response.headers));
|
||||
}
|
||||
if (!response.body) throw new ProviderHttpError("Provider returned an empty event stream.", rateLimitState);
|
||||
return { messages: readServerSentEvents(response.body, timeouts.idleTimeoutMs ?? STREAM_IDLE_TIMEOUT_MS), rateLimitState };
|
||||
@@ -106,7 +108,7 @@ async function requestJson(endpoint: string, init: RequestInit, timeouts: Provid
|
||||
const data = parseProviderJson(raw, response.ok);
|
||||
if (!response.ok) {
|
||||
const message = readProviderError(data) ?? (raw.trim() || `Provider request failed with HTTP ${response.status}.`);
|
||||
throw new ProviderHttpError(message, rateLimitState, response.status);
|
||||
throw new ProviderHttpError(message, rateLimitState, response.status, false, readProviderErrorCode(data, response.headers));
|
||||
}
|
||||
return { data, rateLimitState };
|
||||
}
|
||||
@@ -236,6 +238,33 @@ function readProviderError(data: JsonRecord): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A failed HTTP exchange outside the request helpers (OAuth refresh, token minting), with its status and provider code. */
|
||||
export function httpFailure(message: string, response: { status: number; headers: Headers }, text: string): ProviderHttpError {
|
||||
let data: JsonRecord = {};
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
if (isRecord(parsed)) data = parsed;
|
||||
} catch {
|
||||
data = {};
|
||||
}
|
||||
return new ProviderHttpError(message, parseProviderRateLimitHeaders(response.headers), response.status, false, readProviderErrorCode(data, response.headers));
|
||||
}
|
||||
|
||||
/** The provider's error code: an error object's code, type or status, OAuth's error token, or AWS's error type. */
|
||||
export function readProviderErrorCode(data: JsonRecord, headers?: Headers): string | null {
|
||||
const error = data.error;
|
||||
// OAuth (RFC 6749 5.2) puts the code itself in `error`; a sentence there is a message, not a code.
|
||||
if (typeof error === "string" && /^[A-Za-z0-9_.-]+$/.test(error)) return error;
|
||||
if (isRecord(error)) {
|
||||
for (const key of ["code", "type", "status"]) {
|
||||
const value = error[key];
|
||||
if (typeof value === "string" && value) return value;
|
||||
}
|
||||
}
|
||||
const awsType = headers?.get("x-amzn-errortype") ?? (typeof data.__type === "string" ? data.__type : null);
|
||||
return awsType ? awsType.split(":")[0].split("#").pop() ?? null : null;
|
||||
}
|
||||
|
||||
export function isRecord(value: unknown): value is JsonRecord {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { providerPrices, readTokenUsage, requestCostCents, type TokenUsage } fro
|
||||
/** Providers already logged as not reporting token usage. */
|
||||
const providersWithoutUsage = new Set<string>();
|
||||
import { currentProviderUsageWindow, PROVIDER_MONTHLY_LIMIT_MESSAGE } from "../../shared/provider-runtime-domain";
|
||||
import { ProviderLimitError } from "./provider-failure";
|
||||
|
||||
export interface ProviderRequestOwner {
|
||||
id: string;
|
||||
@@ -69,6 +70,8 @@ export interface ProviderRequestFailureInput {
|
||||
orgId: string;
|
||||
requestId: string;
|
||||
errorMessage: string;
|
||||
errorStatus: number | null;
|
||||
errorCode: string | null;
|
||||
completedAt: string;
|
||||
rawResponseJson?: string | null;
|
||||
}
|
||||
@@ -216,9 +219,9 @@ export class ProviderRequestStore {
|
||||
markFailed(input: ProviderRequestFailureInput): boolean {
|
||||
return this.db.prepare(`
|
||||
update provider_requests
|
||||
set state = ?, error_message = ?, raw_response_json = coalesce(?, raw_response_json), completed_at = ?
|
||||
set state = ?, error_message = ?, error_status = ?, error_code = ?, raw_response_json = coalesce(?, raw_response_json), completed_at = ?
|
||||
where id = ? and org_id = ? and state not in ('succeeded', 'failed', 'cancelled')
|
||||
`).run(PROVIDER_REQUEST_STATE.FAILED, input.errorMessage, input.rawResponseJson ?? null, input.completedAt, input.requestId, input.orgId).changes === 1;
|
||||
`).run(PROVIDER_REQUEST_STATE.FAILED, input.errorMessage, input.errorStatus, input.errorCode, input.rawResponseJson ?? null, input.completedAt, input.requestId, input.orgId).changes === 1;
|
||||
}
|
||||
|
||||
markCancelled(input: ProviderRequestCancellationInput): void {
|
||||
@@ -282,7 +285,7 @@ export class ProviderRequestStore {
|
||||
select count(*) as n from provider_requests
|
||||
where org_id = ? and provider_id = ? and state in (?, ?) and created_at >= ?
|
||||
`).get(orgId, provider.id, PROVIDER_REQUEST_STATE.PENDING, PROVIDER_REQUEST_STATE.STREAMING, window.start) as { n: number };
|
||||
if ((finished?.request_count ?? 0) + inFlight.n >= provider.monthly_request_limit) throw new Error(PROVIDER_MONTHLY_LIMIT_MESSAGE);
|
||||
if ((finished?.request_count ?? 0) + inFlight.n >= provider.monthly_request_limit) throw new ProviderLimitError(PROVIDER_MONTHLY_LIMIT_MESSAGE);
|
||||
}
|
||||
|
||||
/** Settles a successful call: reads the provider's token usage, prices it, and adds it to the request and the month's totals. */
|
||||
|
||||
@@ -6,6 +6,7 @@ import { OperationsService } from "./operations-service";
|
||||
import { providerRateLimitStateFromError } from "./provider-adapters";
|
||||
import { ProviderRateLimitRegistry } from "./provider-rate-limit-registry";
|
||||
import { ProviderRequestStore } from "./provider-request-store";
|
||||
import { ProviderHttpError } from "./provider-http";
|
||||
|
||||
const HEALTH_STATE_HEALTHY = "healthy";
|
||||
const HEALTH_STATE_FAILED = "failed";
|
||||
@@ -73,6 +74,8 @@ export class ProviderResponseObserver {
|
||||
orgId: input.orgId,
|
||||
requestId: input.requestId,
|
||||
errorMessage: message,
|
||||
errorStatus: input.error instanceof ProviderHttpError ? input.error.status : null,
|
||||
errorCode: input.error instanceof ProviderHttpError ? input.error.code : null,
|
||||
completedAt: new Date().toISOString(),
|
||||
rawResponseJson: input.rawResponseJson,
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ import { budgetBlock, isPriced, promptChars, providerPrices } from "./spend";
|
||||
import { assertModelSelectable, effectiveModel } from "./task-run-preparation";
|
||||
import { modelCheckFailure, modelCheckProblem, modelCheckTurn, readModelCheck, recordModelCheck } from "./model-check";
|
||||
import type { ModelCheckResult } from "../../shared/desktop-api";
|
||||
import { isRecord, ProviderHttpError, readProviderErrorCode } from "./provider-http";
|
||||
import {
|
||||
HEALTH_STATE_HEALTHY,
|
||||
WORKSPACE_SCOPES,
|
||||
@@ -248,7 +249,10 @@ export class ProviderResponseRunner {
|
||||
});
|
||||
recordObservedRateLimit(this.deps.rateLimitRegistry, input.orgId, execution.provider, execution.provider.model, result.rateLimitState);
|
||||
for await (const event of result.events) {
|
||||
if (event.type === "error") throw new Error(event.textDelta ?? "Provider stream failed.");
|
||||
if (event.type === "error") {
|
||||
const failure = streamErrorFields(event.rawEventJson);
|
||||
throw new ProviderHttpError(event.textDelta ?? "Provider stream failed.", null, failure.status, false, failure.code);
|
||||
}
|
||||
rawEvents.push(event.rawEventJson);
|
||||
if (event.type === "usage") continue;
|
||||
if (event.type === "message_stop") finished = true;
|
||||
@@ -441,3 +445,12 @@ function requireCredentialId(provider: ProviderRow): string {
|
||||
if (!provider.credential_id) throw new Error("Provider credential is required.");
|
||||
return provider.credential_id;
|
||||
}
|
||||
|
||||
/** A stream error event's status (a numeric error code, as OpenRouter and Google send) and provider code. */
|
||||
function streamErrorFields(rawEventJson: string): { status: number | null; code: string | null } {
|
||||
const data = JSON.parse(rawEventJson) as unknown;
|
||||
if (!isRecord(data)) return { status: null, code: null };
|
||||
const error = data.error ?? (isRecord(data.response) ? data.response.error : undefined);
|
||||
const status = isRecord(error) && typeof error.code === "number" ? error.code : null;
|
||||
return { status, code: readProviderErrorCode({ error }) ?? (typeof data.code === "string" ? data.code : null) };
|
||||
}
|
||||
@@ -65,6 +65,11 @@ export async function* mapOpenAiResponseStream(messages: AsyncIterable<ProviderE
|
||||
export async function* mapOpenAiChatCompletionStream(messages: AsyncIterable<ProviderEventStreamMessage>): AsyncIterable<ProviderStreamEvent> {
|
||||
let started = false;
|
||||
for await (const message of messages) {
|
||||
// A mid-stream failure (OpenRouter) arrives as an error object; it ends the stream with the provider's own words.
|
||||
if (message.data.error !== undefined) {
|
||||
yield streamEvent("error", readErrorText(message.data) ?? "Provider stream failed.", message);
|
||||
return;
|
||||
}
|
||||
const delta = readChatDeltaContent(message.data);
|
||||
if (delta) {
|
||||
if (!started) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** OpenAI Chat Completions encoding/decoding (xAI, Groq, Mistral, Cerebras, OpenRouter, Kimi family). */
|
||||
import { isRecord, ProviderHttpError, type JsonRecord, type ProviderEventStreamMessage } from "../provider-http";
|
||||
import { isRecord, ProviderHttpError, readProviderErrorCode, type JsonRecord, type ProviderEventStreamMessage } from "../provider-http";
|
||||
import { FINISHED_STOP_REASONS, replayIn, requireFinished, type AgentTurnMessage, type AgentTurnResult, type AssistantReplay, toolCallFromJson, type ToolCall, type ToolDefinition } from "../provider-tool-turn";
|
||||
|
||||
/** How each Chat Completions provider names the output cap: OpenAI's current field, or the older one the others document. */
|
||||
@@ -94,7 +94,7 @@ export async function accumulateChatStream(messages: AsyncIterable<ProviderEvent
|
||||
let finishReason: unknown = null;
|
||||
for await (const message of messages) {
|
||||
raw.push(message.rawData);
|
||||
if (message.data.error !== undefined) throw new ProviderHttpError(streamErrorText(message.data.error), null, streamErrorStatus(message.data.error));
|
||||
if (message.data.error !== undefined) throw new ProviderHttpError(streamErrorText(message.data.error), null, streamErrorStatus(message.data.error), false, isRecord(message.data.error) ? readProviderErrorCode({ error: message.data.error }) : null);
|
||||
const choices = Array.isArray(message.data.choices) ? message.data.choices : [];
|
||||
const first = choices.length > 0 && isRecord(choices[0]) ? choices[0] : null;
|
||||
if (first && typeof first.finish_reason === "string") finishReason = first.finish_reason;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* per service account until shortly before expiry.
|
||||
*/
|
||||
import { createSign } from "node:crypto";
|
||||
import { httpFailure } from "../provider-http";
|
||||
|
||||
interface ServiceAccount {
|
||||
client_email: string;
|
||||
@@ -48,7 +49,7 @@ export async function getGcpAccessToken(serviceAccountJson: string): Promise<str
|
||||
body: new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", assertion }),
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) throw new Error(`Vertex authentication failed: ${response.status} ${text}`);
|
||||
if (!response.ok) throw httpFailure(`Vertex authentication failed: ${response.status} ${text}`, response, text);
|
||||
const json = JSON.parse(text) as { access_token?: unknown; expires_in?: unknown };
|
||||
if (typeof json.access_token !== "string" || typeof json.expires_in !== "number") {
|
||||
throw new Error("Vertex authentication response was missing an access token.");
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Antigravity request wrapper in google-gemini-cli.
|
||||
*/
|
||||
import { OUTPUT_RESERVE_TOKENS } from "../context-budget";
|
||||
import { isRecord, ProviderHttpError, type JsonRecord, type ProviderEventStreamMessage } from "../provider-http";
|
||||
import { isRecord, ProviderHttpError, readProviderErrorCode, type JsonRecord, type ProviderEventStreamMessage } from "../provider-http";
|
||||
import { FINISHED_STOP_REASONS, replayIn, requireFinished, toolCallFromObject, type AgentTurnMessage, type AgentTurnResult, type AssistantReplay, type ToolCall, type ToolDefinition } from "../provider-tool-turn";
|
||||
|
||||
const ANTIGRAVITY_SYSTEM_INSTRUCTION =
|
||||
@@ -147,7 +147,7 @@ export async function accumulateGeminiStream(messages: AsyncIterable<ProviderEve
|
||||
raw.push(message.rawData);
|
||||
if (isRecord(message.data.error)) {
|
||||
const error = message.data.error;
|
||||
throw new ProviderHttpError(typeof error.message === "string" ? error.message : "Antigravity stream failed.", null, typeof error.code === "number" ? error.code : null);
|
||||
throw new ProviderHttpError(typeof error.message === "string" ? error.message : "Antigravity stream failed.", null, typeof error.code === "number" ? error.code : null, false, readProviderErrorCode({ error }));
|
||||
}
|
||||
const response = isRecord(message.data.response) ? message.data.response : null;
|
||||
if (!response) continue;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* (Codex) and github-copilot turns. Matches Pi's `convertResponsesMessages` /
|
||||
* `convertResponsesTools` and the Responses streaming event shapes.
|
||||
*/
|
||||
import { isRecord, ProviderHttpError, type JsonRecord, type ProviderEventStreamMessage } from "../provider-http";
|
||||
import { isRecord, ProviderHttpError, readProviderErrorCode, type JsonRecord, type ProviderEventStreamMessage } from "../provider-http";
|
||||
import { incompleteTurn, replayIn, toolCallFromJson, type AgentTurnMessage, type AgentTurnResult, type AssistantReplay, type ToolCall, type ToolDefinition } from "../provider-tool-turn";
|
||||
|
||||
export function responsesToolDefs(tools: ToolDefinition[], strict: boolean | null = false): unknown[] {
|
||||
@@ -84,9 +84,6 @@ function eventType(message: ProviderEventStreamMessage): string | null {
|
||||
return typeof type === "string" ? type : message.eventName;
|
||||
}
|
||||
|
||||
/** Responses error codes that name passing provider trouble, as HTTP statuses for failure classification. */
|
||||
const RESPONSES_ERROR_STATUS: Record<string, number> = { server_error: 500, rate_limit_exceeded: 429 };
|
||||
|
||||
/** The error of an `error` event, or of a `response.failed` event's response. */
|
||||
function streamError(data: JsonRecord): unknown {
|
||||
return data.error ?? (isRecord(data.response) ? data.response.error : undefined);
|
||||
@@ -100,10 +97,10 @@ function errorText(data: JsonRecord): string {
|
||||
return "Provider stream failed.";
|
||||
}
|
||||
|
||||
function errorStatus(data: JsonRecord): number | null {
|
||||
/** The error's code: the error object's code or type, or the event's own code. */
|
||||
function errorCode(data: JsonRecord): string | null {
|
||||
const error = streamError(data);
|
||||
const code = isRecord(error) ? error.code : data.code;
|
||||
return typeof code === "string" ? RESPONSES_ERROR_STATUS[code] ?? null : null;
|
||||
return isRecord(error) ? readProviderErrorCode({ error }) : typeof data.code === "string" ? data.code : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,7 +120,7 @@ export async function accumulateResponsesStream(messages: AsyncIterable<Provider
|
||||
raw.push(message.rawData);
|
||||
const type = eventType(message);
|
||||
if (!type) continue;
|
||||
if (type === "error" || type === "response.failed") throw new ProviderHttpError(errorText(message.data), null, errorStatus(message.data));
|
||||
if (type === "error" || type === "response.failed") throw new ProviderHttpError(errorText(message.data), null, null, false, errorCode(message.data));
|
||||
if (type === "response.output_item.done" && isRecord(message.data.item)) {
|
||||
streamed.push(message.data.item);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user