Skip to content
Open
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
19 changes: 18 additions & 1 deletion docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ ocx logout <provider>
| `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 coding models. |
| `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research subscription gateway (same backend Hermes Agent uses). Device-grant login against `portal.nousresearch.com`; the access token is the per-request inference JWT. Mixed paid + `:free` model catalog (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, ...) discovered live from the signed-in account. Refresh tokens are single-use and rotated on every refresh. |
| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Initial login imports the installed, signed-in `kiro-cli` session (on Unix, install with `curl -fsSL https://cli.kiro.dev/install` &#124; `bash`; on Windows PowerShell, use `irm 'https://cli.kiro.dev/install.ps1'` &#124; `iex`; then run `kiro-cli login`). **Add account** logs `kiro-cli` out, starts a fresh browser login that switches the account used by `kiro-cli`, and stores account-scoped profile metadata. Existing OpenCodex accounts are preserved, and cancellation or failure restores the previous `kiro-cli` session. |
| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth over the Cloud Code Assist wire. Live discovery uses CCA's authenticated `v1internal:fetchAvailableModels` endpoint and publishes the agent models available to the signed-in account; the maintained catalog remains the fallback. |
| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth over the Cloud Code Assist wire. Live discovery uses CCA's authenticated `v1internal:fetchAvailableModels` endpoint and publishes the agent models available to the signed-in account; the maintained catalog remains the fallback. See [Claude on Antigravity](#claude-on-antigravity-cloud-code-assist) below. |
| `cursor` | `cursor` | `https://api2.cursor.sh` | Experimental PKCE login, live HTTP/2 transport with an opt-in HTTP/1.1 compatibility path, and account-filtered model discovery. |
| `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Experimental. GitHub device flow + `copilot_internal` exchange (VS Code OAuth client). Requires an active Copilot subscription; not an official third-party API. |

Expand Down Expand Up @@ -153,6 +153,23 @@ cat accounts.json | ocx account import google-antigravity --format cockpit-tools

Inline JSON and extra positional arguments are rejected. Keep exported files private and delete or store them securely after import.

### Claude on Antigravity (Cloud Code Assist)

The `google-antigravity` provider routes Claude models through Google's Cloud Code Assist (Antigravity)
wire rather than Anthropic's native API. opencodex translates requests and responses at the Gemini
format envelope: tool use/result pairing follows Anthropic semantics (including stable `functionCall.id`
/ `functionResponse.id` fields), and Claude thinking blocks keep their `thoughtSignature` values across
turns.

CCA Claude models reject histories that end with an assistant (model) turn — upstream treats that as
prefill. opencodex strips trailing model turns when safe and appends a `(continue)` user nudge when the
history would otherwise end on model output (for example after context compaction or interrupted-turn
replay). Histories that already end on a user message or tool result are left unchanged.

Antigravity exposes only SSE transport. Unary (non-streaming) callers still go through the same
`parseStream` path; plain JSON bodies without `data:` framing are rejected as truncated SSE rather
than parsed as a separate JSON response format.

### OAuth reliability

opencodex coordinates token refresh and Codex pool routing so concurrent requests do not race the
Expand Down
26 changes: 26 additions & 0 deletions src/adapters/google-antigravity-hosts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
const DAILY_ANTIGRAVITY_HOST = "https://daily-cloudcode-pa.googleapis.com";
const PROD_ANTIGRAVITY_HOST = "https://cloudcode-pa.googleapis.com";

/**
* Return the configured Antigravity endpoint and, for Google's known daily/prod hosts
* only, its daily/production peer. Custom baseUrl values stay single-host.
*/
export function antigravityHostCandidates(configuredBase: string): string[] {
const configured = configuredBase.replace(/\/+$/, "");
if (configured === DAILY_ANTIGRAVITY_HOST) {
return [DAILY_ANTIGRAVITY_HOST, PROD_ANTIGRAVITY_HOST];
}
if (configured === PROD_ANTIGRAVITY_HOST) {
return [PROD_ANTIGRAVITY_HOST, DAILY_ANTIGRAVITY_HOST];
}
return [configured];
}

/** OAuth bearer requests must not use a cleartext host, even if generic baseUrl config allows http. */
export function isAntigravityHttpsHost(host: string): boolean {
try {
return new URL(host).protocol === "https:";
} catch {
return false;
}
}
94 changes: 94 additions & 0 deletions src/adapters/google-antigravity-tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import type {
OcxAssistantMessage,
OcxMessage,
OcxToolCall,
OcxToolResultMessage,
} from "../types";

function isAssistantToolCall(message: OcxMessage): message is OcxAssistantMessage {
return message.role === "assistant";
}

function isToolResult(message: OcxMessage): message is OcxToolResultMessage {
return message.role === "toolResult";
}

/**
* Repair incomplete tool exchanges before assigning provider-visible ids.
*
* CCA translates Gemini function calls and responses into Anthropic tool blocks,
* which requires both sides of every exchange. A result is valid only when its
* call appeared earlier in the history, and a call is valid only when a result
* appears later. Filtering the history first also prevents orphan results from
* reserving ids in the request-scoped allocator.
*
* The allocator maps one raw id to one wire id, so a second complete exchange
* that reuses the same raw id would serialize as a colliding pair. Keep only
* the first matched occurrence per raw id.
*/
export function repairGoogleToolPairs(messages: readonly OcxMessage[]): OcxMessage[] {
const pendingCalls = new Map<string, Array<{ messageIndex: number; partIndex: number }>>();
const seenRawCallIds = new Set<string>();
const matchedCallParts = new Set<string>();
const matchedResultIndexes = new Set<number>();

const enqueueCall = (id: string, messageIndex: number, partIndex: number) => {
if (seenRawCallIds.has(id)) return;
seenRawCallIds.add(id);
const queue = pendingCalls.get(id) ?? [];
queue.push({ messageIndex, partIndex });
pendingCalls.set(id, queue);
};

for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
const message = messages[messageIndex]!;
if (isAssistantToolCall(message)) {
message.content.forEach((part, partIndex) => {
if (part.type !== "toolCall") return;
enqueueCall((part as OcxToolCall).id, messageIndex, partIndex);
});
continue;
}
if (!isToolResult(message)) continue;
const queue = pendingCalls.get(message.toolCallId);
const slot = queue?.shift();
if (!slot) continue;
matchedCallParts.add(`${slot.messageIndex}:${slot.partIndex}`);
matchedResultIndexes.add(messageIndex);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

const repaired: OcxMessage[] = [];
for (const [messageIndex, message] of messages.entries()) {
if (isToolResult(message)) {
if (matchedResultIndexes.has(messageIndex)) repaired.push(message);
continue;
}
if (!isAssistantToolCall(message)) {
repaired.push(message);
continue;
}

const content = message.content.filter((part, partIndex) =>
part.type !== "toolCall" || matchedCallParts.has(`${messageIndex}:${partIndex}`));
if (content.length > 0) {
repaired.push(content.length === message.content.length ? message : { ...message, content });
}
}
return repaired;
}

/**
* Claude interprets a final model turn as a prefilled assistant response.
* CCA expects the next turn to be generated instead, except when that model
* turn is the entire conversation and must remain as the initial context.
*/
export function stripTrailingClaudePrefill(contents: unknown[]): boolean {
let strippedModelTail = false;
while (contents.length >= 2) {
const last = contents[contents.length - 1];
if (typeof last !== "object" || last === null || (last as { role?: unknown }).role !== "model") break;
contents.pop();
strippedModelTail = true;
}
return strippedModelTail;
}
7 changes: 7 additions & 0 deletions src/adapters/google-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ function googleErrorDetail(payloadText: string): { message?: string; status?: st
};
}

const ANTIGRAVITY_GEO_BLOCKED_MARKER = "user location is not supported for the api use";

export function isAntigravityGeoBlockedBody(payloadText: string): boolean {
return payloadText.toLowerCase().includes(ANTIGRAVITY_GEO_BLOCKED_MARKER);
}

function classifyGoogle(label: string, status: number | undefined, enumStatus: string | undefined, text: string): string {
const lower = `${enumStatus ?? ""} ${text}`.toLowerCase();
const quotaExhausted =
Expand All @@ -29,6 +35,7 @@ function classifyGoogle(label: string, status: number | undefined, enumStatus: s
if (status === 401 || enumStatus === "UNAUTHENTICATED" || lower.includes("unauthenticated") || lower.includes("invalid authentication") || lower.includes("expired")) {
return `${label} authentication failed`;
}
if (isAntigravityGeoBlockedBody(lower)) return `${label} location not supported`;
if (status === 403 || enumStatus === "PERMISSION_DENIED" || lower.includes("permission denied") || lower.includes("access denied")) {
return `${label} access denied`;
}
Expand Down
Loading
Loading