otterly 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,20 @@
1
+ import type { IncomingMessage, ServerResponse } from "http";
2
+ import type { ServerContext } from "./routes-native.js";
3
+ export declare function checkAuth(req: IncomingMessage, ctx: ServerContext): boolean;
4
+ export interface RateLimiterOptions {
5
+ requestsPerMinute?: number;
6
+ }
7
+ export declare class RateLimiter {
8
+ private requestsPerMinute;
9
+ private buckets;
10
+ private cleanupInterval;
11
+ constructor(opts?: RateLimiterOptions);
12
+ /** Returns true if allowed, false if rate-limited. */
13
+ allow(key: string): boolean;
14
+ /** Get the client key from a request (IP-based). */
15
+ keyFor(req: IncomingMessage): string;
16
+ destroy(): void;
17
+ private cleanup;
18
+ }
19
+ export declare function sendAuthError(res: ServerResponse, format: "openai" | "native"): void;
20
+ export declare function sendRateLimitError(res: ServerResponse, format: "openai" | "native"): void;
@@ -0,0 +1,80 @@
1
+ // Shared auth check and token-bucket rate limiter.
2
+ // ── Auth ──
3
+ export function checkAuth(req, ctx) {
4
+ if (!ctx.apiKey)
5
+ return true;
6
+ const authHeader = req.headers["authorization"] || "";
7
+ const token = authHeader.replace(/^Bearer\s+/i, "");
8
+ return token === ctx.apiKey;
9
+ }
10
+ export class RateLimiter {
11
+ requestsPerMinute;
12
+ buckets = new Map();
13
+ cleanupInterval;
14
+ constructor(opts = {}) {
15
+ this.requestsPerMinute = opts.requestsPerMinute ?? 60;
16
+ // Sweep stale buckets every 5 minutes
17
+ this.cleanupInterval = setInterval(() => this.cleanup(), 5 * 60 * 1000);
18
+ this.cleanupInterval.unref();
19
+ }
20
+ /** Returns true if allowed, false if rate-limited. */
21
+ allow(key) {
22
+ const now = Date.now();
23
+ let bucket = this.buckets.get(key);
24
+ if (!bucket) {
25
+ bucket = { tokens: this.requestsPerMinute, lastRefill: now };
26
+ this.buckets.set(key, bucket);
27
+ }
28
+ // Refill tokens based on elapsed time
29
+ const elapsed = now - bucket.lastRefill;
30
+ const refill = (elapsed / 60_000) * this.requestsPerMinute;
31
+ bucket.tokens = Math.min(this.requestsPerMinute, bucket.tokens + refill);
32
+ bucket.lastRefill = now;
33
+ if (bucket.tokens >= 1) {
34
+ bucket.tokens -= 1;
35
+ return true;
36
+ }
37
+ return false;
38
+ }
39
+ /** Get the client key from a request (IP-based). */
40
+ keyFor(req) {
41
+ return req.headers["x-forwarded-for"]?.split(",")[0]?.trim()
42
+ || req.socket.remoteAddress
43
+ || "unknown";
44
+ }
45
+ destroy() {
46
+ clearInterval(this.cleanupInterval);
47
+ }
48
+ cleanup() {
49
+ const now = Date.now();
50
+ const staleMs = 10 * 60 * 1000; // 10 minutes
51
+ for (const [key, bucket] of this.buckets) {
52
+ if (now - bucket.lastRefill > staleMs) {
53
+ this.buckets.delete(key);
54
+ }
55
+ }
56
+ }
57
+ }
58
+ // ── Middleware helpers ──
59
+ export function sendAuthError(res, format) {
60
+ res.writeHead(401, { "Content-Type": "application/json" });
61
+ if (format === "openai") {
62
+ res.end(JSON.stringify({
63
+ error: { message: "Invalid API key", type: "authentication_error", code: 401 },
64
+ }));
65
+ }
66
+ else {
67
+ res.end(JSON.stringify({ error: "Invalid API key" }));
68
+ }
69
+ }
70
+ export function sendRateLimitError(res, format) {
71
+ res.writeHead(429, { "Content-Type": "application/json" });
72
+ if (format === "openai") {
73
+ res.end(JSON.stringify({
74
+ error: { message: "Rate limit exceeded. Try again later.", type: "rate_limit_error", code: 429 },
75
+ }));
76
+ }
77
+ else {
78
+ res.end(JSON.stringify({ error: "Rate limit exceeded. Try again later." }));
79
+ }
80
+ }
@@ -0,0 +1,110 @@
1
+ import type { AgentResult } from "../types.js";
2
+ export interface OpenAIContentPart {
3
+ type: "text" | "image_url";
4
+ text?: string;
5
+ image_url?: {
6
+ url: string;
7
+ detail?: string;
8
+ };
9
+ }
10
+ export interface OpenAIChatMessage {
11
+ role: "system" | "user" | "assistant";
12
+ content: string | OpenAIContentPart[];
13
+ }
14
+ export interface OpenAIToolFunction {
15
+ name: string;
16
+ description?: string;
17
+ parameters?: Record<string, unknown>;
18
+ }
19
+ export interface OpenAITool {
20
+ type: "function";
21
+ function: OpenAIToolFunction;
22
+ }
23
+ export interface OpenAIChatRequest {
24
+ model?: string;
25
+ messages: OpenAIChatMessage[];
26
+ stream?: boolean;
27
+ temperature?: number;
28
+ max_tokens?: number;
29
+ response_format?: {
30
+ type: "text" | "json_object";
31
+ };
32
+ tools?: OpenAITool[];
33
+ tool_choice?: string | Record<string, unknown>;
34
+ }
35
+ export interface OpenAIChatResponse {
36
+ id: string;
37
+ object: "chat.completion";
38
+ created: number;
39
+ model: string;
40
+ choices: Array<{
41
+ index: number;
42
+ message: {
43
+ role: "assistant";
44
+ content: string;
45
+ };
46
+ finish_reason: "stop";
47
+ }>;
48
+ usage: {
49
+ prompt_tokens: number;
50
+ completion_tokens: number;
51
+ total_tokens: number;
52
+ };
53
+ }
54
+ export interface OpenAIStreamChunk {
55
+ id: string;
56
+ object: "chat.completion.chunk";
57
+ created: number;
58
+ model: string;
59
+ choices: Array<{
60
+ index: number;
61
+ delta: {
62
+ role?: string;
63
+ content?: string;
64
+ };
65
+ finish_reason: string | null;
66
+ }>;
67
+ }
68
+ /**
69
+ * Convert OpenAI chat messages into a prompt string + systemPrompt
70
+ * for the otterly engine. Detects multimodal content.
71
+ */
72
+ export declare function openaiToClaudeInput(body: OpenAIChatRequest): {
73
+ prompt: string;
74
+ systemPrompt: string | null;
75
+ isMultimodal: boolean;
76
+ };
77
+ /**
78
+ * Map OpenAI tools parameter to Claude Code allowedTools names.
79
+ * Level 1: treat tool function names as direct Claude Code tool name filters.
80
+ */
81
+ export declare function openaiToolsToAllowedTools(tools: OpenAITool[]): string[];
82
+ /**
83
+ * Build a non-streaming OpenAI chat completion response.
84
+ */
85
+ export declare function claudeResultToOpenai(text: string, model: string, usage?: AgentResult["usage"]): OpenAIChatResponse;
86
+ /**
87
+ * Build a streaming SSE chunk.
88
+ */
89
+ export declare function makeStreamChunk(id: string, delta: {
90
+ role?: string;
91
+ content?: string;
92
+ }, finishReason: string | null, model: string): OpenAIStreamChunk;
93
+ /**
94
+ * Format a chunk as an SSE data line.
95
+ */
96
+ export declare function sseData(obj: unknown): string;
97
+ /**
98
+ * Map error to HTTP status code.
99
+ */
100
+ export declare function errorToHttpStatus(err: unknown): number;
101
+ /**
102
+ * Build an OpenAI-style error response body.
103
+ */
104
+ export declare function openaiErrorBody(status: number, message: string): {
105
+ error: {
106
+ message: string;
107
+ type: string;
108
+ code: number;
109
+ };
110
+ };
@@ -0,0 +1,158 @@
1
+ // OpenAI format <-> otterly format translation.
2
+ // Pure functions, no I/O.
3
+ import crypto from "crypto";
4
+ // ── Conversion Functions ──
5
+ /**
6
+ * Convert OpenAI chat messages into a prompt string + systemPrompt
7
+ * for the otterly engine. Detects multimodal content.
8
+ */
9
+ export function openaiToClaudeInput(body) {
10
+ const messages = body.messages || [];
11
+ let systemPrompt = null;
12
+ const conversationParts = [];
13
+ let isMultimodal = false;
14
+ for (const msg of messages) {
15
+ if (msg.role === "system") {
16
+ systemPrompt = typeof msg.content === "string" ? msg.content : "";
17
+ }
18
+ else if (msg.role === "user") {
19
+ if (typeof msg.content === "string") {
20
+ conversationParts.push(msg.content);
21
+ }
22
+ else if (Array.isArray(msg.content)) {
23
+ // Multimodal content: extract text parts, note image presence
24
+ const textParts = [];
25
+ for (const part of msg.content) {
26
+ if (part.type === "text" && part.text) {
27
+ textParts.push(part.text);
28
+ }
29
+ else if (part.type === "image_url") {
30
+ isMultimodal = true;
31
+ textParts.push("[Image provided]");
32
+ }
33
+ }
34
+ conversationParts.push(textParts.join("\n"));
35
+ }
36
+ }
37
+ else if (msg.role === "assistant") {
38
+ const text = typeof msg.content === "string" ? msg.content : "";
39
+ conversationParts.push(`[Previous assistant response]: ${text}`);
40
+ }
41
+ }
42
+ let prompt;
43
+ if (conversationParts.length <= 1) {
44
+ prompt = conversationParts[0] || "";
45
+ }
46
+ else {
47
+ const context = conversationParts.slice(0, -1).join("\n\n");
48
+ const lastMessage = conversationParts[conversationParts.length - 1];
49
+ prompt = `Previous conversation context:\n${context}\n\n---\n\nCurrent message:\n${lastMessage}`;
50
+ }
51
+ return { prompt, systemPrompt, isMultimodal };
52
+ }
53
+ /**
54
+ * Map OpenAI tools parameter to Claude Code allowedTools names.
55
+ * Level 1: treat tool function names as direct Claude Code tool name filters.
56
+ */
57
+ export function openaiToolsToAllowedTools(tools) {
58
+ // Known Claude Code tool names
59
+ const KNOWN_TOOLS = new Set([
60
+ "Read", "Write", "Edit", "MultiEdit", "Bash", "Glob", "Grep",
61
+ "WebFetch", "WebSearch", "Task", "NotebookEdit", "AskUserQuestion",
62
+ ]);
63
+ const allowed = [];
64
+ for (const tool of tools) {
65
+ const name = tool.function?.name;
66
+ if (name && KNOWN_TOOLS.has(name)) {
67
+ allowed.push(name);
68
+ }
69
+ }
70
+ return allowed;
71
+ }
72
+ // ── Response Builders ──
73
+ /**
74
+ * Build a non-streaming OpenAI chat completion response.
75
+ */
76
+ export function claudeResultToOpenai(text, model, usage) {
77
+ const id = `chatcmpl-otterly-${crypto.randomUUID().slice(0, 12)}`;
78
+ const inputTokens = usage?.input_tokens || 0;
79
+ const outputTokens = usage?.output_tokens || 0;
80
+ return {
81
+ id,
82
+ object: "chat.completion",
83
+ created: Math.floor(Date.now() / 1000),
84
+ model: model || "claude-sonnet-4-20250514",
85
+ choices: [
86
+ {
87
+ index: 0,
88
+ message: { role: "assistant", content: text || "" },
89
+ finish_reason: "stop",
90
+ },
91
+ ],
92
+ usage: {
93
+ prompt_tokens: inputTokens,
94
+ completion_tokens: outputTokens,
95
+ total_tokens: inputTokens + outputTokens,
96
+ },
97
+ };
98
+ }
99
+ /**
100
+ * Build a streaming SSE chunk.
101
+ */
102
+ export function makeStreamChunk(id, delta, finishReason, model) {
103
+ return {
104
+ id,
105
+ object: "chat.completion.chunk",
106
+ created: Math.floor(Date.now() / 1000),
107
+ model: model || "claude-sonnet-4-20250514",
108
+ choices: [
109
+ {
110
+ index: 0,
111
+ delta,
112
+ finish_reason: finishReason,
113
+ },
114
+ ],
115
+ };
116
+ }
117
+ /**
118
+ * Format a chunk as an SSE data line.
119
+ */
120
+ export function sseData(obj) {
121
+ return `data: ${JSON.stringify(obj)}\n\n`;
122
+ }
123
+ /**
124
+ * Map error to HTTP status code.
125
+ */
126
+ export function errorToHttpStatus(err) {
127
+ const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
128
+ if (msg.includes("anthropic_api_key") || msg.includes("authentication") || msg.includes("not_authenticated") || msg.includes("not logged in")) {
129
+ return 401;
130
+ }
131
+ if (msg.includes("rate_limit") || msg.includes("429"))
132
+ return 429;
133
+ if (msg.includes("billing") || msg.includes("402"))
134
+ return 402;
135
+ if (msg.includes("econnrefused") || msg.includes("fetch failed") || msg.includes("network"))
136
+ return 502;
137
+ if (msg.includes("abort"))
138
+ return 499;
139
+ return 500;
140
+ }
141
+ /**
142
+ * Build an OpenAI-style error response body.
143
+ */
144
+ export function openaiErrorBody(status, message) {
145
+ const typeMap = {
146
+ 401: "authentication_error",
147
+ 429: "rate_limit_error",
148
+ 402: "billing_error",
149
+ 502: "network_error",
150
+ };
151
+ return {
152
+ error: {
153
+ message,
154
+ type: typeMap[status] || "server_error",
155
+ code: status,
156
+ },
157
+ };
158
+ }
@@ -0,0 +1,33 @@
1
+ export interface QueueOptions {
2
+ maxConcurrent?: number;
3
+ maxQueueSize?: number;
4
+ queueTimeoutMs?: number;
5
+ }
6
+ export interface QueueStats {
7
+ running: number;
8
+ queued: number;
9
+ maxConcurrent: number;
10
+ maxQueueSize: number;
11
+ totalProcessed: number;
12
+ totalRejected: number;
13
+ }
14
+ export declare class RequestQueue {
15
+ private maxConcurrent;
16
+ private maxQueueSize;
17
+ private queueTimeoutMs;
18
+ private running;
19
+ private queue;
20
+ private totalProcessed;
21
+ private totalRejected;
22
+ constructor(opts?: QueueOptions);
23
+ run<T>(fn: () => Promise<T>): Promise<T>;
24
+ stats(): QueueStats;
25
+ private acquire;
26
+ private release;
27
+ }
28
+ export declare class QueueFullError extends Error {
29
+ constructor();
30
+ }
31
+ export declare class QueueTimeoutError extends Error {
32
+ constructor();
33
+ }
@@ -0,0 +1,79 @@
1
+ // Semaphore-based concurrency limiter with bounded queue.
2
+ // Prevents fork-bombing when many requests arrive simultaneously.
3
+ export class RequestQueue {
4
+ maxConcurrent;
5
+ maxQueueSize;
6
+ queueTimeoutMs;
7
+ running = 0;
8
+ queue = [];
9
+ totalProcessed = 0;
10
+ totalRejected = 0;
11
+ constructor(opts = {}) {
12
+ this.maxConcurrent = opts.maxConcurrent ?? 5;
13
+ this.maxQueueSize = opts.maxQueueSize ?? 50;
14
+ this.queueTimeoutMs = opts.queueTimeoutMs ?? 30_000;
15
+ }
16
+ async run(fn) {
17
+ await this.acquire();
18
+ try {
19
+ const result = await fn();
20
+ this.totalProcessed++;
21
+ return result;
22
+ }
23
+ finally {
24
+ this.release();
25
+ }
26
+ }
27
+ stats() {
28
+ return {
29
+ running: this.running,
30
+ queued: this.queue.length,
31
+ maxConcurrent: this.maxConcurrent,
32
+ maxQueueSize: this.maxQueueSize,
33
+ totalProcessed: this.totalProcessed,
34
+ totalRejected: this.totalRejected,
35
+ };
36
+ }
37
+ acquire() {
38
+ if (this.running < this.maxConcurrent) {
39
+ this.running++;
40
+ return Promise.resolve();
41
+ }
42
+ if (this.queue.length >= this.maxQueueSize) {
43
+ this.totalRejected++;
44
+ return Promise.reject(new QueueFullError());
45
+ }
46
+ return new Promise((resolve, reject) => {
47
+ const timer = setTimeout(() => {
48
+ const idx = this.queue.findIndex((e) => e.resolve === resolve);
49
+ if (idx !== -1)
50
+ this.queue.splice(idx, 1);
51
+ this.totalRejected++;
52
+ reject(new QueueTimeoutError());
53
+ }, this.queueTimeoutMs);
54
+ this.queue.push({ resolve, reject, timer });
55
+ });
56
+ }
57
+ release() {
58
+ if (this.queue.length > 0) {
59
+ const next = this.queue.shift();
60
+ clearTimeout(next.timer);
61
+ next.resolve();
62
+ }
63
+ else {
64
+ this.running--;
65
+ }
66
+ }
67
+ }
68
+ export class QueueFullError extends Error {
69
+ constructor() {
70
+ super("Server is at capacity. Try again later.");
71
+ this.name = "QueueFullError";
72
+ }
73
+ }
74
+ export class QueueTimeoutError extends Error {
75
+ constructor() {
76
+ super("Request timed out waiting in queue.");
77
+ this.name = "QueueTimeoutError";
78
+ }
79
+ }
@@ -0,0 +1,28 @@
1
+ import type { IncomingMessage, ServerResponse } from "http";
2
+ import type { RequestQueue } from "./request-queue.js";
3
+ import type { CircuitBreaker } from "./circuit-breaker.js";
4
+ export interface ServerContext {
5
+ workingDir: string;
6
+ apiKey: string | null;
7
+ }
8
+ export interface ParsedRequest extends IncomingMessage {
9
+ body?: Record<string, unknown>;
10
+ requestId?: string;
11
+ startTime?: number;
12
+ timeoutSignal?: AbortSignal;
13
+ }
14
+ /**
15
+ * GET /api/status — health check with queue and circuit breaker stats
16
+ */
17
+ export declare function handleStatus(_req: IncomingMessage, res: ServerResponse, queue?: RequestQueue, circuitBreaker?: CircuitBreaker): void;
18
+ /**
19
+ * POST /api/run — one-shot execution, returns full result.
20
+ * Supports session reuse via X-Session-Id header or session_id in body.
21
+ */
22
+ export declare function handleRun(req: ParsedRequest, res: ServerResponse, ctx: ServerContext, circuitBreaker?: CircuitBreaker): Promise<void>;
23
+ /**
24
+ * POST /api/stream — streaming execution, NDJSON.
25
+ * Supports session reuse via X-Session-Id header or session_id in body.
26
+ * Includes backpressure: waits for drain when write buffer is full.
27
+ */
28
+ export declare function handleStream(req: ParsedRequest, res: ServerResponse, ctx: ServerContext, circuitBreaker?: CircuitBreaker): Promise<void>;