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
6 changes: 3 additions & 3 deletions src/server/request-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ export function requestLogEntryFromPersistedUsage(entry: PersistedUsageEntry): R
usageStatus: entry.usageStatus,
...(entry.usage ? { usage: entry.usage } : {}),
...(entry.totalTokens !== undefined ? { totalTokens: entry.totalTokens } : {}),
...(entry.attempts?.length ? { attempts: entry.attempts } : {}),
...(entry.attempts !== undefined ? { attempts: entry.attempts } : {}),
...(routeDecision ? { routeDecision } : {}),
};
}
Expand Down Expand Up @@ -348,7 +348,7 @@ export function addRequestLog(entry: RequestLogEntry) {
usageStatus: entry.usageStatus,
...(entry.usage ? { usage: entry.usage } : {}),
...(entry.totalTokens !== undefined ? { totalTokens: entry.totalTokens } : {}),
...(entry.attempts?.length ? { attempts: entry.attempts } : {}),
...(entry.attempts !== undefined ? { attempts: entry.attempts } : {}),
...failureDiagnostics,
...(entry.routeDecision ? { routeDecision: entry.routeDecision } : {}),
});
Expand Down Expand Up @@ -838,7 +838,7 @@ export function addFinalRequestLog(
usageStatus,
...(loggedUsage ? { usage: loggedUsage } : {}),
...(totalTokens !== undefined ? { totalTokens } : {}),
...(attempts?.length ? { attempts } : {}),
...(attempts !== undefined ? { attempts } : {}),
...(logCtx.affinity ? { affinity: logCtx.affinity } : {}),
...(logCtx.transportPhase ? { transportPhase: logCtx.transportPhase } : {}),
...(logCtx.terminalSource ? { terminalSource: logCtx.terminalSource } : {}),
Expand Down
13 changes: 13 additions & 0 deletions src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1649,6 +1649,19 @@ async function handleResponsesInner(
const adapterProvider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire);
const adapter = resolveAdapter(adapterProvider, config.cacheRetention);
logCtx.providerAdapter = adapter.name;
// Ordinary requests receive one durable attempt only after their final initial
// adapter is resolved. Combo children own their attempt and retries keep it.
if (!options.comboAttempt && !logCtx.activeAttempt) {
const attempt = beginRequestAttempt(
(logCtx.attempts?.length ?? 0) + 1,
logCtx.provider,
route.modelId,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the selected model in ordinary attempts

For OpenAI virtual models such as gpt-5.6-sol-pro, applyOpenAiVirtualModel has already rewritten route.modelId to the wire model (gpt-5.6-sol) while retaining the selected model in logCtx.model. Persisting the rewritten value here changes usage reporting because usageAttributions treats any non-empty attempts array as authoritative and ignores the top-level model/resolvedModel pair. Consequently, all newly logged ordinary Pro requests are grouped and priced as the base model instead of the selected Pro model. Preserve the selected model in the attempt (or carry selected/resolved identities separately and update the usage projection).

Useful? React with 👍 / 👎.

adapter.name,
);
logCtx.activeAttempt = attempt;
logCtx.activeAttemptStartedAt = Date.now();
(logCtx.attempts ??= []).push(attempt);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep ordinary pricing misses out of the combo diagnostic

Once every ordinary request gets this non-empty attempts array, an ordinary request whose usage is absent or whose model has no matched price is misclassified by unavailableCostReason: that function treats any non-empty attempts list as a combo and returns combo_attempt_unavailable before reaching usage_missing or price_unmatched. The Logs dashboard therefore tells users that a combo attempt could not be priced even when no combo was used. Update the management-side classification to distinguish ordinary attempts from combo attempts, or persist that distinction with the attempt data.

Useful? React with 👍 / 👎.

}
sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, adapter.name);
const isPassthrough = "passthrough" in adapter && !!adapter.passthrough;

Expand Down
2 changes: 1 addition & 1 deletion src/usage/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,7 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry {
usageStatus: entry.usageStatus,
...(entry.usage ? { usage: normalizeUsageValue(entry.usage) } : {}),
...(typeof entry.totalTokens === "number" ? { totalTokens: entry.totalTokens } : {}),
...(attempts.length > 0 ? { attempts } : {}),
...(Array.isArray(entry.attempts) ? { attempts } : {}),
...(entry.errorCode ? { errorCode: entry.errorCode } : {}),
...(entry.terminalStatus ? { terminalStatus: entry.terminalStatus } : {}),
...(entry.closeReason ? { closeReason: entry.closeReason } : {}),
Expand Down
79 changes: 77 additions & 2 deletions src/vision/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,72 @@ function renderDescription(out: { text: string; error?: string }): OcxTextConten
};
}

const IMAGE_OMITTED_TEXT = "[image omitted: this model is text-only and the vision sidecar is unavailable (no ChatGPT login)]";

function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

/**
* Keep the native Responses passthrough body aligned with image replacements made in the parsed
* message graph. The passthrough adapter serializes `_rawBody`, while translated adapters serialize
* `context.messages`; updating only the latter would send the original pixels to a text-only
* Responses upstream even after the vision sidecar produced a caption.
*
* Rewrites only image-bearing user/developer messages and tool outputs. All other native Responses
* items (reasoning, calls, ids, compaction, and provider-specific metadata) remain byte-structurally
* untouched.
*/
function syncRawBodyImageDescriptions(parsed: OcxParsedRequest, descriptions: readonly string[]): void {
const rawBody = parsed._rawBody;
if (!isPlainRecord(rawBody) || !Array.isArray(rawBody.input)) return;

let nextDescription = 0;
const rewriteImages = (value: unknown, nonEmptyImageUrlsOnly: boolean): unknown => {
if (Array.isArray(value)) {
let changed = false;
const rewritten = value.map(entry => {
const next = rewriteImages(entry, nonEmptyImageUrlsOnly);
if (next !== entry) changed = true;
return next;
});
return changed ? rewritten : value;
}
if (!isPlainRecord(value)) return value;
if (value.type === "input_image" && typeof value.image_url === "string") {
if (nonEmptyImageUrlsOnly && value.image_url.length === 0) {
return { type: "input_text", text: IMAGE_OMITTED_TEXT };
}
const description = descriptions[nextDescription++];
return { type: "input_text", text: description ?? IMAGE_OMITTED_TEXT };
}
return value;
};

let changed = false;
const input = rawBody.input.map(item => {
if (!isPlainRecord(item)) return item;
const type = typeof item.type === "string" ? item.type : (typeof item.role === "string" ? "message" : "");
const role = typeof item.role === "string" ? item.role : "";
const isMessageContent = (
(type === "message" && (role === "user" || role === "developer"))
|| type === "agent_message"
);
const field = isMessageContent
? "content"
: (type === "function_call_output" || type === "custom_tool_call_output")
? "output"
: undefined;
if (!field) return item;
const rewritten = rewriteImages(item[field], isMessageContent);
if (rewritten === item[field]) return item;
changed = true;
return { ...item, [field]: rewritten };
});

if (changed) rawBody.input = input;
}

function sha256(value: string | Uint8Array): string {
return createHash("sha256").update(value).digest("hex");
}
Expand Down Expand Up @@ -369,7 +435,10 @@ export async function describeImagesInPlace(
}
targets.push({ msg, parts });
}
if (jobs.length === 0) return;
if (jobs.length === 0) {
syncRawBodyImageDescriptions(parsed, []);
return;
}

// 2. Admit misses in source order. Cache hits and same-turn waiters do not consume the cap.
const inFlight = new Map<string, Promise<DescribeOutcome>>();
Expand Down Expand Up @@ -425,6 +494,7 @@ export async function describeImagesInPlace(

// 3. Rebuild each message, replacing image parts with their descriptions in order.
let oi = 0;
const descriptions: string[] = [];
for (const { msg, parts } of targets) {
const newParts: OcxContentPart[] = [];
for (const p of parts) {
Expand All @@ -433,6 +503,7 @@ export async function describeImagesInPlace(
continue;
}
const replacement = renderDescription(outcomes[oi++]);
descriptions.push(replacement.text);
const reservation = translatorBudget?.reserveTransient(
descriptionEncoder.encode(replacement.text).byteLength,
{ kind: "request_copies" },
Expand All @@ -442,6 +513,7 @@ export async function describeImagesInPlace(
}
msg.content = newParts;
}
syncRawBodyImageDescriptions(parsed, descriptions);
}

/**
Expand All @@ -452,13 +524,15 @@ export async function describeImagesInPlace(
*/
export function stripImagesInPlace(parsed: OcxParsedRequest, translatorBudget?: TranslatorBudget): boolean {
let stripped = false;
const descriptions: string[] = [];
for (const msg of parsed.context.messages) {
if (!carriesImages(msg.role) || !Array.isArray(msg.content)) continue;
const parts = msg.content as OcxContentPart[];
if (!parts.some(p => p.type === "image")) continue;
msg.content = parts.map(p => {
if (p.type !== "image") return p;
const replacement = { type: "text", text: "[image omitted: this model is text-only and the vision sidecar is unavailable (no ChatGPT login)]" } as OcxContentPart;
const replacement = { type: "text", text: IMAGE_OMITTED_TEXT } as OcxContentPart;
descriptions.push((replacement as OcxTextContent).text);
const reservation = translatorBudget?.reserveTransient(
descriptionEncoder.encode((replacement as OcxTextContent).text).byteLength,
{ kind: "request_copies" },
Expand All @@ -468,5 +542,6 @@ export function stripImagesInPlace(parsed: OcxParsedRequest, translatorBudget?:
});
stripped = true;
}
syncRawBodyImageDescriptions(parsed, descriptions);
return stripped;
}
61 changes: 60 additions & 1 deletion tests/request-log.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@ import {
sealRequestAttemptIdentity,
type RequestLogContext,
} from "../src/server/request-log";
import { handleResponses } from "../src/server/responses";
import { bridgeToResponsesSSE } from "../src/bridge";
import type { AdapterEvent, OcxUsage } from "../src/types";
import type { AdapterEvent, OcxConfig, OcxUsage } from "../src/types";
import {
appendUsageEntry,
readUsageEntries,
Expand Down Expand Up @@ -53,6 +54,64 @@ function log(overrides: Partial<RequestLogEntry>): RequestLogEntry {
}

describe("request log metadata", () => {
test("creates one ordinary attempt after the final adapter is resolved", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => Response.json({
id: "resp_attempt",
object: "response",
status: "completed",
output: [],
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
})) as typeof fetch;
const logCtx: RequestLogContext = { model: "unknown", provider: "unknown" };
const config = {
defaultProvider: "gateway",
providers: {
gateway: {
adapter: "openai-responses",
authMode: "key",
apiKey: "test-key",
baseUrl: "https://gateway.example/v1",
},
},
} as OcxConfig;

try {
const response = await handleResponses(new Request("http://localhost/v1/responses", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ model: "gateway/test-model", input: "hello", stream: false }),
}), config, logCtx);

expect(response.status).toBe(200);
expect(logCtx.providerAdapter).toBe("openai-responses");
expect(logCtx.attempts).toEqual([expect.objectContaining({
ordinal: 1,
provider: "gateway",
model: "test-model",
adapter: "openai-responses",
sendCount: 1,
})]);
} finally {
globalThis.fetch = originalFetch;
}
});

test("projects explicitly empty attempts from persisted usage", () => {
const projected = requestLogEntryFromPersistedUsage({
requestId: "ocx-empty-attempts",
timestamp: 1,
provider: "openai",
model: "gpt-test",
status: 200,
durationMs: 1,
usageStatus: "unreported",
attempts: [],
});

expect(projected.attempts).toEqual([]);
});

test("records the adapter's exact outbound reasoning parameter", () => {
const attempt = beginRequestAttempt(1, "xai", "grok-4.5", "openai-chat");
const logCtx: RequestLogContext = {
Expand Down
15 changes: 15 additions & 0 deletions tests/usage-log.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,21 @@ afterEach(() => {
});

describe("usage log", () => {
test("preserves explicitly empty attempts through normalization", () => {
const normalized = normalizeUsageEntryForTest({
requestId: "ocx-empty-attempts",
timestamp: 1,
provider: "openai",
model: "gpt-test",
status: 200,
durationMs: 1,
usageStatus: "unreported",
attempts: [],
});

expect(normalized.attempts).toEqual([]);
});

test("persists the rate-limit-429 recovery kind on attempts", () => {
const entry: PersistedUsageEntry = {
requestId: "ocx-ratelimit-kind",
Expand Down
Loading
Loading