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
33 changes: 33 additions & 0 deletions src/adapter/openai-to-cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { extractModel } from "./openai-to-cli.js";

test("extractModel with unprefixed model names", () => {
assert.equal(extractModel("claude-opus-4"), "opus");
assert.equal(extractModel("claude-sonnet-4"), "sonnet");
assert.equal(extractModel("claude-haiku-4"), "haiku");
});

test("extractModel with claude-code-cli/ prefix", () => {
assert.equal(extractModel("claude-code-cli/claude-opus-4"), "opus");
assert.equal(extractModel("claude-code-cli/claude-sonnet-4"), "sonnet");
assert.equal(extractModel("claude-code-cli/claude-haiku-4"), "haiku");
});

test("extractModel with claude-max/ prefix", () => {
assert.equal(extractModel("claude-max/claude-opus-4"), "opus");
assert.equal(extractModel("claude-max/claude-sonnet-4"), "sonnet");
assert.equal(extractModel("claude-max/claude-haiku-4"), "haiku");
});

test("extractModel with short aliases", () => {
assert.equal(extractModel("opus"), "opus");
assert.equal(extractModel("sonnet"), "sonnet");
assert.equal(extractModel("haiku"), "haiku");
});

test("extractModel with unknown model falls back to opus", () => {
assert.equal(extractModel("unknown-model"), "opus");
assert.equal(extractModel("gpt-4"), "opus");
assert.equal(extractModel("some-provider/unknown-model"), "opus");
});
32 changes: 22 additions & 10 deletions src/adapter/openai-to-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Converts OpenAI chat request format to Claude CLI input
*/

import type { OpenAIChatRequest } from "../types/openai.js";
import type { OpenAIChatRequest, OpenAIContentPart } from "../types/openai.js";

export type ClaudeModel = "opus" | "sonnet" | "haiku";

Expand All @@ -17,10 +17,6 @@ const MODEL_MAP: Record<string, ClaudeModel> = {
"claude-opus-4": "opus",
"claude-sonnet-4": "sonnet",
"claude-haiku-4": "haiku",
// With provider prefix
"claude-code-cli/claude-opus-4": "opus",
"claude-code-cli/claude-sonnet-4": "sonnet",
"claude-code-cli/claude-haiku-4": "haiku",
// Aliases
"opus": "opus",
"sonnet": "sonnet",
Expand All @@ -36,8 +32,8 @@ export function extractModel(model: string): ClaudeModel {
return MODEL_MAP[model];
}

// Try stripping provider prefix
const stripped = model.replace(/^claude-code-cli\//, "");
// Try stripping provider prefix (e.g. "claude-max/", "claude-code-cli/")
const stripped = model.replace(/^[^/]+\//, "");
if (MODEL_MAP[stripped]) {
return MODEL_MAP[stripped];
}
Expand All @@ -46,6 +42,21 @@ export function extractModel(model: string): ClaudeModel {
return "opus";
}

/**
* Extract a plain string from a message content field.
* Handles both the simple string form and the array-of-parts form that the
* OpenAI API allows (e.g. [{type:"text", text:"…"}, {type:"image_url",…}]).
* Non-text parts (images, etc.) are silently dropped since the Claude CLI
* only accepts text input.
*/
function contentToString(content: string | OpenAIContentPart[]): string {
if (typeof content === "string") return content;
return content
.filter((p) => p.type === "text" && typeof p.text === "string")
.map((p) => p.text as string)
.join("");
}

/**
* Convert OpenAI messages array to a single prompt string for Claude CLI
*
Expand All @@ -56,20 +67,21 @@ export function messagesToPrompt(messages: OpenAIChatRequest["messages"]): strin
const parts: string[] = [];

for (const msg of messages) {
const text = contentToString(msg.content);
switch (msg.role) {
case "system":
// System messages become context instructions
parts.push(`<system>\n${msg.content}\n</system>\n`);
parts.push(`<system>\n${text}\n</system>\n`);
break;

case "user":
// User messages are the main prompt
parts.push(msg.content);
parts.push(text);
break;

case "assistant":
// Previous assistant responses for context
parts.push(`<previous_response>\n${msg.content}\n</previous_response>\n`);
parts.push(`<previous_response>\n${text}\n</previous_response>\n`);
break;
}
}
Expand Down
50 changes: 50 additions & 0 deletions src/concurrency/request-tracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* Request Tracker
*
* Tracks in-flight requests for health monitoring,
* debugging, and graceful shutdown.
*/

export interface ActiveRequest {
requestId: string;
model: string;
stream: boolean;
startedAt: number;
pid?: number;
}

class RequestTracker {
private requests = new Map<string, ActiveRequest>();

add(info: ActiveRequest): void {
this.requests.set(info.requestId, info);
}

remove(requestId: string): void {
this.requests.delete(requestId);
}

setPid(requestId: string, pid: number): void {
const req = this.requests.get(requestId);
if (req) req.pid = pid;
}

get(requestId: string): ActiveRequest | undefined {
return this.requests.get(requestId);
}

getAll(): ActiveRequest[] {
return Array.from(this.requests.values());
}

get count(): number {
return this.requests.size;
}

/** Check if there are any active requests */
get idle(): boolean {
return this.requests.size === 0;
}
}

export const requestTracker = new RequestTracker();
98 changes: 98 additions & 0 deletions src/concurrency/semaphore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* Async Semaphore for limiting concurrent operations
*
* Used to cap the number of simultaneous Claude CLI subprocesses
* to prevent resource exhaustion under concurrent load.
*/

interface QueueEntry {
resolve: () => void;
reject: (err: Error) => void;
timer: NodeJS.Timeout | null;
}

export class Semaphore {
private current = 0;
private readonly queue: QueueEntry[] = [];

constructor(
private readonly max: number,
private readonly queueTimeout: number = 120000 // 2 minutes default
) {}

/**
* Acquire a permit. Resolves immediately if capacity is available,
* otherwise queues and waits. Rejects if queue timeout expires.
*/
async acquire(): Promise<void> {
if (this.current < this.max) {
this.current++;
return;
}

return new Promise<void>((resolve, reject) => {
const entry: QueueEntry = {
resolve: () => {
if (entry.timer) clearTimeout(entry.timer);
resolve();
},
reject,
timer: null,
};

entry.timer = setTimeout(() => {
const idx = this.queue.indexOf(entry);
if (idx !== -1) {
this.queue.splice(idx, 1);
reject(
new Error(
`Request queued too long (${this.queueTimeout}ms). ` +
`${this.current} of ${this.max} slots busy, ${this.queue.length} still queued.`
)
);
}
}, this.queueTimeout);

this.queue.push(entry);
});
}

/**
* Release a permit. If requests are queued, the next one gets the slot.
*/
release(): void {
if (this.queue.length > 0) {
const next = this.queue.shift()!;
// Don't decrement — slot transfers directly to next waiter
next.resolve();
} else {
this.current--;
}
}

/** Number of active permits */
get active(): number {
return this.current;
}

/** Number of requests waiting in queue */
get waiting(): number {
return this.queue.length;
}

/** Maximum concurrent permits */
get capacity(): number {
return this.max;
}

/**
* Drain all waiters with an error (used during shutdown).
*/
drain(reason: string): void {
while (this.queue.length > 0) {
const entry = this.queue.shift()!;
if (entry.timer) clearTimeout(entry.timer);
entry.reject(new Error(reason));
}
}
}
Loading