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
13 changes: 13 additions & 0 deletions docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,19 @@ active account without logging the others out. Identity-less Kimi and Kiro crede
active slot, while `chatgpt` is always single-slot because Codex pool accounts have a separate ledger.
Tokens stay in `~/.opencodex/auth.json`; `/api/oauth/accounts` returns masked metadata only.

### Kiro credential import

`ocx login kiro` searches the platform Kiro CLI stores and opens SQLite databases read-only. Two
environment variables make selection explicit without copying credentials into opencodex:

- `KIROCLI_DB_PATH` selects a nonstandard Kiro CLI SQLite database. The path must already exist;
opencodex does not create it or modify the database, WAL, or SHM files.
- `KIROCLI_TOKEN_KEY` selects the exact `auth_kv` token key when a database contains multiple
otherwise ambiguous token rows. A missing selection fails login instead of guessing.

Keep these variables and the selected database private. Do not attach database files or raw login
diagnostics to bug reports.

## 3. API-key catalog

opencodex ships 53 built-in presets: 42 key-based, seven OAuth, three local, and the default
Expand Down
34 changes: 32 additions & 2 deletions docs-site/src/content/docs/reference/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,38 @@ streams the response back **untranslated**.
by the Kiro wire.
- Decodes `application/vnd.amazon.eventstream`, reconstructs text/thinking/tool events, detects
truncated tool JSON, and estimates usage because the upstream does not return token counts.
- Owns bounded retries and classified/redacted errors through `fetchResponse`; its non-streaming
parser drains the same event stream for the web-search loop.
- Uses the configured `baseUrl` verbatim when it is custom. A canonical
`runtime.{region}.kiro.dev` URL follows the imported credential's API region; only that canonical
shape is eligible for one bounded fallback to `q.{region}.amazonaws.com` after an endpoint,
signature, DNS, or connection failure.
- Owns replay-safe connection-reset recovery, that single eligible endpoint fallback, and one OAuth
refresh/replay after HTTP 401. The client owns throttling, timeout, and ordinary service retries;
opencodex does not multiply those policies inside the adapter.
- Its non-streaming parser drains the same event stream for the web-search loop.

### Completion semantics

Kiro text events do not carry a dependable end-turn phase. When an ordinary client tool is present,
opencodex therefore adds a private `codex_kiro_final_answer` tool to the upstream request. Progress
text streams as commentary and cannot terminate the turn. The adapter consumes the private call,
emits its answer as final text, and never exposes the private tool to Codex or Claude Code.
When the web-search sidecar is active, this commentary still streams immediately; only the events
needed to decide whether the model requested a synthetic search remain buffered.

If Kiro emits progress without calling the completion tool, the adapter makes one continuation. That
single retry may finish with a validated private completion or plain final text. It cannot recurse:
an empty or reasoning-only retry is returned as retryable incomplete, while a real client tool call
keeps the turn open. If the retry only repeats the preceding commentary after whitespace
normalization, the duplicate output is suppressed while the turn still completes. Tool-free
requests retain normal text completion behavior.

### Reasoning effort

`gpt-5.6-sol` has verified native effort support. Its selected `low`, `medium`, `high`, `xhigh`, or
`max` value is sent as `additionalModelRequestFields.reasoning.effort`. Other Kiro models currently
use emulated reasoning: opencodex converts the selected level into bounded thinking instructions in
the user content because their native effort field has not been verified. Do not interpret an
advertised effort control on those models as proof of upstream-native reasoning support.

## `cursor`

Expand Down
12 changes: 12 additions & 0 deletions src/adapters/kiro-constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export const KIRO_COMPLETION_TOOL_NAME = "codex_kiro_final_answer";
export const KIRO_CONTINUATION_MESSAGE = "[system: conversation continues]";
export const KIRO_COMPLETION_RETRY_MESSAGE =
`[system: The preceding assistant output did not explicitly complete the turn. If the task is complete, call ${KIRO_COMPLETION_TOOL_NAME} now with the complete final answer. Otherwise issue the next real tool call now. Do not ask the user for another task or emit another progress-only message.]`;

export const KIRO_COMPLETION_INSTRUCTIONS =
`When tools are available, ordinary assistant text is mid-task commentary and does not end the turn. Continue using tools after progress updates. When the task is fully complete and no more tool calls are needed, call ${KIRO_COMPLETION_TOOL_NAME} exactly once with the complete user-facing final answer in \`answer\`. Do not provide the final answer as ordinary assistant text.`;

export type KiroCompletionMode = "disabled" | "required" | "text_fallback";

/** Bound proxy-authored prompt additions independently of caller-owned instructions/history. */
export const MAX_KIRO_INJECTED_INSTRUCTION_CHARS = 16_384;
113 changes: 111 additions & 2 deletions src/adapters/kiro-errors.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { parseUpstreamJsonPayload, safeUpstreamErrorString, sanitizeUpstreamErrorText } from "./upstream-http-error";
const DETAIL_KEYS = ["__type", "code", "error", "name", "message", "Message", "errorMessage"];
const DETAIL_KEYS = ["__type", "code", "error", "name", "reason", "message", "Message", "errorMessage"];

export interface KiroErrorClassification {
message: string;
status: number;
errorType: string;
code: string;
retryable: boolean;
}

function headerValue(headers: Headers | Record<string, unknown>, name: string): string | undefined {
if (headers instanceof Headers) return name.startsWith(":") ? undefined : safeUpstreamErrorString(headers.get(name));
Expand Down Expand Up @@ -79,10 +87,111 @@ function normalizedKiroErrorMessage(headers: Headers | Record<string, unknown>,
return detail ? `${prefix}: ${detail}` : prefix;
}

function isContentLengthError(text: string): boolean {
const lower = text.toLowerCase();
return lower.includes("content_length_exceeds_threshold") || lower.includes("content length exceeds");
}

function classifyKiroFailure(
headers: Headers | Record<string, unknown>,
payloadText: string,
status?: number,
): KiroErrorClassification {
const message = normalizedKiroErrorMessage(headers, payloadText, status);
const headerType = headerValue(headers, ":exception-type") || headerValue(headers, ":error-type") || "";
const evidence = [headerType, ...payloadDetails(payloadText), message].join(" ").toLowerCase();
if (isContentLengthError(evidence)) {
return {
message: "Kiro rejected the request because the conversation exceeds the model's context window. Compact or reduce the history, or start a new session.",
status: 400,
errorType: "invalid_request_error",
code: "context_length_exceeded",
retryable: false,
};
}
if (
evidence.includes("insufficient_quota")
|| evidence.includes("quota exhausted")
|| evidence.includes("quota exceeded")
) {
return { message, status: 429, errorType: "insufficient_quota", code: "insufficient_quota", retryable: false };
}
if (
status === 429
|| evidence.includes("throttlingexception")
|| evidence.includes("too many requests")
|| evidence.includes("rate limit")
) {
return { message, status: 429, errorType: "rate_limit_error", code: "rate_limit_exceeded", retryable: true };
}
if (
status === 401
|| status === 403
|| evidence.includes("accessdenied")
|| evidence.includes("unauthorized")
|| evidence.includes("unrecognizedclient")
|| evidence.includes("expiredtoken")
|| evidence.includes("expired token")
|| evidence.includes("invalid token")
|| evidence.includes("authentication")
) {
return { message, status: status === 403 ? 403 : 401, errorType: status === 403 ? "permission_error" : "authentication_error", code: status === 403 ? "permission_denied" : "invalid_api_key", retryable: false };
}
if (
status === 400
|| evidence.includes("validationexception")
|| evidence.includes("invalid request")
|| evidence.includes("model unavailable")
|| evidence.includes("model not found")
|| evidence.includes("unsupported model")
|| evidence.includes("profile arn")
|| evidence.includes("malformed")
) {
return { message, status: 400, errorType: "invalid_request_error", code: "invalid_request_error", retryable: false };
}
if (
status === 503
|| evidence.includes("overloaded")
|| evidence.includes("server is busy")
|| evidence.includes("temporarily unavailable")
) {
return { message, status: 503, errorType: "server_error", code: "server_is_overloaded", retryable: true };
}
return {
message,
status: status && status >= 500 ? status : 502,
errorType: "server_error",
code: "upstream_server_error",
retryable: true,
};
}

export function safeKiroErrorMessage(headers: Record<string, unknown>, payloadText: string): string {
return normalizedKiroErrorMessage(headers, payloadText);
}

export function classifyKiroStreamError(
headers: Record<string, unknown>,
payloadText: string,
): KiroErrorClassification {
return classifyKiroFailure(headers, payloadText);
}

export function classifyKiroHttpError(
status: number,
headers: Headers | Record<string, unknown>,
payloadText: string,
): KiroErrorClassification {
return classifyKiroFailure(headers, payloadText, status);
}

export function classifyKiroEventError(reason: string | undefined, message: string | undefined): KiroErrorClassification {
const safeReason = reason ? sanitizeUpstreamErrorText(reason).slice(0, 160) : "";
const safeMessage = message ? sanitizeUpstreamErrorText(message).slice(0, 500) : "Kiro request failed";
const payload = JSON.stringify({ reason: safeReason, message: safeMessage });
return classifyKiroFailure({}, payload);
}

export function safeKiroHttpErrorMessage(status: number, headers: Headers | Record<string, unknown>, payloadText: string): string {
return normalizedKiroErrorMessage(headers, payloadText, status);
return classifyKiroFailure(headers, payloadText, status).message;
}
Loading