@hackerrank/astra-cli 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.
package/src/model.js ADDED
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Minimal model client for the HackerRank AI Gateway.
3
+ *
4
+ * Zero external dependencies. Uses the OpenAI-compatible
5
+ * /chat/completions endpoint exposed by the gateway.
6
+ *
7
+ * The gateway (provider "hackerrank-ai-gateway") speaks the
8
+ * "openai-completions" API at:
9
+ * https://gateway-central.ai.private.hackerrank.link/v1
10
+ *
11
+ * Configure via environment:
12
+ * ASTRA_GATEWAY_BASE_URL (default: the gateway URL above)
13
+ * ASTRA_GATEWAY_API_KEY (required)
14
+ */
15
+
16
+ const DEFAULT_BASE_URL = "https://gateway-central.ai.private.hackerrank.link/v1";
17
+ import { estimateCost } from "./prices.js";
18
+
19
+ /** Thrown when the request exceeds the model's context window (terminal). */
20
+ export class ContextWindowError extends Error {
21
+ constructor(message) {
22
+ super(message);
23
+ this.name = "ContextWindowError";
24
+ }
25
+ }
26
+
27
+ /** Detect a context-length-exceeded response across provider dialects. */
28
+ export function isContextWindowError(status, text) {
29
+ if (status !== 400) return false;
30
+ return /context[_ ]length|context window|maximum context|too many tokens|reduce the length|input is too long|exceeds the (?:maximum|context)/i.test(
31
+ text || ""
32
+ );
33
+ }
34
+
35
+ export class GatewayModel {
36
+ /**
37
+ * @param {object} opts
38
+ * @param {string} opts.model model id, e.g. "claude-sonnet-5"
39
+ * @param {string} [opts.baseUrl]
40
+ * @param {string} [opts.apiKey]
41
+ * @param {object} [opts.modelKwargs] extra body params (temperature, etc.)
42
+ * @param {number} [opts.maxRetries]
43
+ * @param {(info:object)=>void} [opts.onRetry] called before each retry sleep
44
+ */
45
+ constructor({ model, baseUrl, apiKey, modelKwargs = {}, maxRetries = 5, maxTokens = 8192, onRetry } = {}) {
46
+ if (!model) throw new Error("GatewayModel: `model` is required");
47
+ this.model = model;
48
+ this.maxTokens = maxTokens;
49
+ this.baseUrl = (baseUrl || process.env.ASTRA_GATEWAY_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, "");
50
+ this.apiKey = apiKey || process.env.ASTRA_GATEWAY_API_KEY || "";
51
+ this.modelKwargs = modelKwargs;
52
+ this.maxRetries = maxRetries;
53
+ this.onRetry = onRetry || (() => {});
54
+ this.nCalls = 0;
55
+ // Cumulative token usage across all calls (exact, from the API).
56
+ this.totalPromptTokens = 0;
57
+ this.totalCompletionTokens = 0;
58
+ // Cumulative USD cost. `reported` = summed from the gateway's usage.cost;
59
+ // `estimated` = summed from the public price table (prices.js).
60
+ this.totalCostUsd = 0;
61
+ this.reportedCostUsd = 0;
62
+ this.estimatedCostUsd = 0;
63
+ this.costSource = null; // "reported" | "estimated" | "mixed" | null
64
+ // Preferred token-cap parameter; swapped automatically on 400 if needed.
65
+ this._tokenParam = "max_tokens";
66
+ if (!this.apiKey) {
67
+ throw new Error(
68
+ "GatewayModel: no API key. Set ASTRA_GATEWAY_API_KEY or configure ~/.astra/config.json."
69
+ );
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Query the model with a full message list.
75
+ * @param {Array<{role:string, content:string}>} messages
76
+ * @returns {Promise<{content:string, raw:object, usage:object}>}
77
+ */
78
+ async query(messages) {
79
+ // Different models on the gateway want different token-cap params
80
+ // (`max_tokens` vs `max_completion_tokens`). We start with a preferred
81
+ // key and transparently swap if the gateway rejects it.
82
+ const body = {
83
+ model: this.model,
84
+ messages: messages.map((m) => ({ role: m.role, content: m.content })),
85
+ [this._tokenParam]: this.maxTokens,
86
+ ...this.modelKwargs,
87
+ };
88
+
89
+ let lastErr;
90
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
91
+ try {
92
+ const res = await fetch(`${this.baseUrl}/chat/completions`, {
93
+ method: "POST",
94
+ headers: {
95
+ "Content-Type": "application/json",
96
+ Authorization: `Bearer ${this.apiKey}`,
97
+ },
98
+ body: JSON.stringify(body),
99
+ });
100
+
101
+ if (!res.ok) {
102
+ const text = await res.text().catch(() => "");
103
+ // A context-window overflow is terminal, not retryable: surface it
104
+ // as a distinct error so the agent can end the run cleanly.
105
+ if (isContextWindowError(res.status, text)) {
106
+ throw new ContextWindowError(`Gateway HTTP ${res.status}: ${text.slice(0, 500)}`);
107
+ }
108
+ // Auto-swap the token-cap parameter if the model rejects it.
109
+ if (res.status === 400 && this._maybeSwapTokenParam(text, body)) {
110
+ continue;
111
+ }
112
+ // Retry on transient server / rate-limit errors with exponential
113
+ // backoff (honoring Retry-After when the server provides it).
114
+ if ((res.status === 429 || res.status >= 500) && attempt < this.maxRetries) {
115
+ const wait = retryAfterMs(res.headers) ?? backoffMs(attempt);
116
+ this.onRetry({
117
+ attempt: attempt + 1,
118
+ maxRetries: this.maxRetries,
119
+ status: res.status,
120
+ waitMs: wait,
121
+ reason: `HTTP ${res.status}`,
122
+ });
123
+ await sleep(wait);
124
+ continue;
125
+ }
126
+ // Non-retryable HTTP error: throw a classified, actionable error.
127
+ throw new GatewayError(res.status, text);
128
+ }
129
+
130
+ const data = await res.json();
131
+ this.nCalls++;
132
+ const content = data?.choices?.[0]?.message?.content ?? "";
133
+ const usage = normalizeUsage(data?.usage);
134
+ this.totalPromptTokens += usage.prompt_tokens;
135
+ this.totalCompletionTokens += usage.completion_tokens;
136
+ // Cost: prefer the gateway's exact number; otherwise estimate from the
137
+ // public price table. Attach per-call cost + source to usage.
138
+ const cost = this._accountCost(usage);
139
+ usage.cost_usd = cost.usd;
140
+ usage.cost_kind = cost.kind; // "reported" | "estimated" | "unknown"
141
+ return { content, raw: data, usage };
142
+ } catch (err) {
143
+ lastErr = err;
144
+ // Network hiccups -> retry with backoff. Classified/terminal errors
145
+ // (auth, quota, context window) are re-thrown immediately.
146
+ if (attempt < this.maxRetries && isRetryable(err)) {
147
+ const wait = backoffMs(attempt);
148
+ this.onRetry({
149
+ attempt: attempt + 1,
150
+ maxRetries: this.maxRetries,
151
+ status: 0,
152
+ waitMs: wait,
153
+ reason: shortErr(err),
154
+ });
155
+ await sleep(wait);
156
+ continue;
157
+ }
158
+ throw err;
159
+ }
160
+ }
161
+ throw lastErr;
162
+ }
163
+
164
+ /**
165
+ * Compute and accumulate the USD cost of one call. Prefers the gateway's
166
+ * exact `usage.cost` (reported); falls back to the public price table
167
+ * (estimated). Updates totals and the aggregate cost source.
168
+ * @returns {{usd:number|null, kind:"reported"|"estimated"|"unknown"}}
169
+ */
170
+ _accountCost(usage) {
171
+ let usd = null;
172
+ let kind = "unknown";
173
+ if (usage.reported_cost != null) {
174
+ usd = usage.reported_cost;
175
+ kind = "reported";
176
+ this.reportedCostUsd += usd;
177
+ } else {
178
+ const est = estimateCost(this.model, usage);
179
+ if (est != null) {
180
+ usd = est;
181
+ kind = "estimated";
182
+ this.estimatedCostUsd += usd;
183
+ }
184
+ }
185
+ if (usd != null) {
186
+ this.totalCostUsd += usd;
187
+ this.costSource =
188
+ this.costSource == null || this.costSource === kind ? kind : "mixed";
189
+ }
190
+ return { usd, kind };
191
+ }
192
+
193
+ /**
194
+ * If the gateway rejects our token-cap param, swap it in-place on the body
195
+ * and record the new preference. Returns true if a swap happened.
196
+ */
197
+ _maybeSwapTokenParam(errText, body) {
198
+ const wantsCompletion =
199
+ /max_completion_tokens/.test(errText) && /max_tokens/.test(errText);
200
+ const wantsMaxTokens =
201
+ /max_tokens/.test(errText) &&
202
+ /not supported|unsupported/.test(errText) &&
203
+ "max_completion_tokens" in body;
204
+ if (wantsCompletion && "max_tokens" in body) {
205
+ delete body.max_tokens;
206
+ body.max_completion_tokens = this.maxTokens;
207
+ this._tokenParam = "max_completion_tokens";
208
+ return true;
209
+ }
210
+ if (wantsMaxTokens) {
211
+ delete body.max_completion_tokens;
212
+ body.max_tokens = this.maxTokens;
213
+ this._tokenParam = "max_tokens";
214
+ return true;
215
+ }
216
+ return false;
217
+ }
218
+ }
219
+
220
+ function isRetryable(err) {
221
+ // Never retry classified terminal errors (auth/quota/bad-request) or context
222
+ // overflow — those won't succeed on retry.
223
+ if (err instanceof GatewayError || err instanceof ContextWindowError) return false;
224
+ const msg = String(err?.message || err);
225
+ return /HTTP 5\d\d|HTTP 429|ECONNRESET|ETIMEDOUT|fetch failed|network/i.test(msg);
226
+ }
227
+
228
+ /** A classified, non-retryable HTTP error from the gateway with a hint. */
229
+ export class GatewayError extends Error {
230
+ constructor(status, body) {
231
+ const hint = hintForStatus(status);
232
+ const snippet = String(body || "").slice(0, 300);
233
+ super(`Gateway HTTP ${status}${hint ? ` — ${hint}` : ""}${snippet ? `\n ${snippet}` : ""}`);
234
+ this.name = "GatewayError";
235
+ this.status = status;
236
+ this.hint = hint;
237
+ }
238
+ }
239
+
240
+ /** Human-readable, actionable guidance per HTTP status. */
241
+ function hintForStatus(status) {
242
+ switch (status) {
243
+ case 401:
244
+ case 403:
245
+ return "authentication failed — check your API key (--api-key / ASTRA_GATEWAY_API_KEY / ~/.astra/config.json)";
246
+ case 402:
247
+ return "payment/quota required — the account has no credits for this model/route";
248
+ case 404:
249
+ return "model or endpoint not found — check the model id and --base-url";
250
+ case 429:
251
+ return "rate limited";
252
+ default:
253
+ if (status >= 500) return "gateway/server error";
254
+ return "";
255
+ }
256
+ }
257
+
258
+ /** Parse a Retry-After header (seconds or HTTP-date) into milliseconds. */
259
+ function retryAfterMs(headers) {
260
+ const v = headers?.get?.("retry-after");
261
+ if (!v) return null;
262
+ const secs = Number(v);
263
+ if (Number.isFinite(secs)) return Math.max(0, secs * 1000);
264
+ const when = Date.parse(v);
265
+ if (Number.isFinite(when)) return Math.max(0, when - Date.now());
266
+ return null;
267
+ }
268
+
269
+ /** Short one-line description of an error for retry logs. */
270
+ function shortErr(err) {
271
+ return String(err?.message || err).split("\n")[0].slice(0, 120);
272
+ }
273
+
274
+ function backoffMs(attempt) {
275
+ const base = Math.min(1000 * 2 ** attempt, 30000);
276
+ return base + Math.floor(Math.random() * 500);
277
+ }
278
+
279
+ function sleep(ms) {
280
+ return new Promise((r) => setTimeout(r, ms));
281
+ }
282
+
283
+ /**
284
+ * Normalize the provider's `usage` object into a consistent shape.
285
+ * All gateway families return OpenAI-style usage; we keep the exact counts
286
+ * and surface cached prompt tokens when reported (Option C: provider truth,
287
+ * no local tokenizer / estimation).
288
+ */
289
+ export function normalizeUsage(usage) {
290
+ const u = usage || {};
291
+ const prompt = num(u.prompt_tokens);
292
+ const completion = num(u.completion_tokens);
293
+ const total = u.total_tokens != null ? num(u.total_tokens) : prompt + completion;
294
+ const cached = num(u.prompt_tokens_details?.cached_tokens);
295
+ const cacheWrite = num(u.prompt_tokens_details?.cache_write_tokens);
296
+ const reasoning = num(u.completion_tokens_details?.reasoning_tokens);
297
+ // Some routes (OpenRouter-proxied) report the exact USD cost. Keep it when
298
+ // present so we never have to estimate for those models.
299
+ const reportedCost = u.cost != null && Number.isFinite(Number(u.cost)) ? Number(u.cost) : null;
300
+ return {
301
+ prompt_tokens: prompt,
302
+ completion_tokens: completion,
303
+ total_tokens: total,
304
+ cached_tokens: cached,
305
+ cache_write_tokens: cacheWrite,
306
+ reasoning_tokens: reasoning,
307
+ reported_cost: reportedCost,
308
+ cost_details: u.cost_details || null,
309
+ };
310
+ }
311
+
312
+ function num(v) {
313
+ return Number.isFinite(v) ? v : 0;
314
+ }
package/src/prices.js ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Public list prices for cost ESTIMATION — used only for models whose gateway
3
+ * responses do NOT include an exact `usage.cost` (the "native" routes:
4
+ * Anthropic Claude, OpenAI GPT, Google Gemini).
5
+ *
6
+ * For the OpenRouter-proxied routes (deepseek, glm, grok, kimi, qwen) the
7
+ * gateway returns an exact `usage.cost` in USD, so those are NOT listed here —
8
+ * we use the reported number directly and never touch this table.
9
+ *
10
+ * IMPORTANT — these are ESTIMATES:
11
+ * - The gateway exposes internal aliases (e.g. `gpt-5.6-sol`, `claude-opus-5`,
12
+ * `gemini-3.7-flash`) that do not map 1:1 to a public SKU. Each entry below
13
+ * maps an alias to the CLOSEST current public tier and cites the source.
14
+ * - Prices are USD per 1,000,000 tokens (the unit every vendor publishes).
15
+ * - `cachedInput` is the discounted rate for cached prompt tokens where the
16
+ * vendor publishes one; if omitted, cached tokens are billed at `input`.
17
+ * - Edit any number here in one place if you have the real gateway pricing.
18
+ *
19
+ * Sources (public list prices, captured for reference):
20
+ * Anthropic: https://www.anthropic.com/pricing
21
+ * OpenAI: https://openai.com/api/pricing/
22
+ * Google: https://ai.google.dev/gemini-api/docs/pricing
23
+ */
24
+
25
+ // USD per 1M tokens. cachedInput optional.
26
+ export const PRICES = {
27
+ // --- Anthropic Claude (native route, no reported cost) ---------------------
28
+ // Mapped to Claude Opus tier (highest). Source: anthropic.com/pricing.
29
+ "claude-opus-5": { input: 15.0, output: 75.0, cachedInput: 1.5, note: "~Claude Opus tier" },
30
+ // Mapped to Claude Sonnet tier. Source: anthropic.com/pricing.
31
+ "claude-sonnet-5": { input: 3.0, output: 15.0, cachedInput: 0.3, note: "~Claude Sonnet tier" },
32
+
33
+ // --- OpenAI GPT (native route, no reported cost) ---------------------------
34
+ // The gpt-5.6-* aliases (luna/sol/terra) are treated as one GPT-5-class tier.
35
+ // Mapped to GPT-5 public pricing. Source: openai.com/api/pricing.
36
+ "gpt-5.6-luna": { input: 1.25, output: 10.0, cachedInput: 0.125, note: "~GPT-5 tier" },
37
+ "gpt-5.6-sol": { input: 1.25, output: 10.0, cachedInput: 0.125, note: "~GPT-5 tier" },
38
+ "gpt-5.6-terra": { input: 1.25, output: 10.0, cachedInput: 0.125, note: "~GPT-5 tier" },
39
+
40
+ // --- Google Gemini (native route, no reported cost) ------------------------
41
+ // Mapped to Gemini Flash tier. Source: ai.google.dev/gemini-api/docs/pricing.
42
+ "gemini-3.7-flash": { input: 0.3, output: 2.5, cachedInput: 0.075, note: "~Gemini Flash tier" },
43
+ };
44
+
45
+ /**
46
+ * Estimate the USD cost of one call from token counts + the table above.
47
+ * Returns null when we have no price entry for the model (unknown alias).
48
+ */
49
+ export function estimateCost(model, usage) {
50
+ const p = PRICES[model];
51
+ if (!p) return null;
52
+ const prompt = usage.prompt_tokens || 0;
53
+ const cached = Math.min(usage.cached_tokens || 0, prompt);
54
+ const uncached = prompt - cached;
55
+ const completion = usage.completion_tokens || 0;
56
+ const inRate = p.input / 1e6;
57
+ const cacheRate = (p.cachedInput ?? p.input) / 1e6;
58
+ const outRate = p.output / 1e6;
59
+ return uncached * inRate + cached * cacheRate + completion * outRate;
60
+ }
package/src/prompts.js ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Prompt templates + a tiny {{var}} renderer.
3
+ *
4
+ * We use a TEXT-based action protocol (a single fenced bash block) rather than
5
+ * provider tool-calling, so the exact same harness works with every model on
6
+ * the gateway.
7
+ *
8
+ * The system prompt is assembled from a shared BASE plus a mode-specific block:
9
+ * - AUTONOMOUS_RULES when a --task is given (benchmark / run-to-completion)
10
+ * - INTERACTIVE_RULES for the chat REPL (a personal SWE assistant)
11
+ */
12
+
13
+ export const BASE_SYSTEM = `You are a precise software engineering agent working in a shell.
14
+
15
+ To run a shell command, your response MUST contain exactly ONE bash code block
16
+ containing ONE command (you may chain with && or ||). Explain your reasoning in
17
+ plain text BEFORE the command block. Format exactly like <format_example>.
18
+
19
+ <format_example>
20
+ Your reasoning here: what you observed and what you will do next.
21
+
22
+ \`\`\`bash
23
+ your_command_here
24
+ \`\`\`
25
+ </format_example>
26
+
27
+ Shell rules:
28
+ 1. A command-running response contains exactly one bash code block, one command.
29
+ 2. Directory and environment changes are NOT persistent — every command runs in
30
+ a fresh subshell. Chain state in a single command, e.g.
31
+ \`cd /path && VAR=1 some_command\`.
32
+
33
+ System: {{system}} {{release}} {{machine}} | cwd: {{cwd}}`;
34
+
35
+ export const AUTONOMOUS_RULES = `
36
+ Autonomous mode:
37
+ - Do NOT ask the user questions; work autonomously until the task is done.
38
+ - You are permitted and expected to read and use any available credentials,
39
+ environment variables, and config files needed to complete the task.
40
+ - When completely finished, run this and NOTHING else:
41
+ \`echo COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT\`
42
+ After that command you cannot continue.`;
43
+
44
+ export const INTERACTIVE_RULES = `
45
+ Interactive mode:
46
+ - You are a helpful pair-programming assistant. Work with the user turn by turn.
47
+ - To run a shell command, use the bash block as described above.
48
+ - To talk to the user (answer a question, ask for clarification, summarize, or
49
+ report you are done) respond with plain text and NO bash block. That hands
50
+ the turn back to the user.
51
+ - Prefer small, verifiable steps. Do not fabricate command output.`;
52
+
53
+ export const INSTANCE_TEMPLATE = `Please complete this task:
54
+
55
+ {{task}}
56
+
57
+ Recommended workflow:
58
+ 1. Explore the repository / working directory to understand context.
59
+ 2. Make focused edits to implement the required behavior.
60
+ 3. Verify by building and/or running tests.
61
+ 4. Iterate until it works, then submit.
62
+
63
+ When done, submit by running exactly:
64
+ \`echo COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT\``;
65
+
66
+ export const FORMAT_ERROR = `Your last response could not be parsed.
67
+ {{error}}
68
+
69
+ Respond again with exactly ONE bash code block containing ONE command, like:
70
+
71
+ \`\`\`bash
72
+ your_command_here
73
+ \`\`\``;
74
+
75
+ export const OBSERVATION_TEMPLATE = `{{exception}}<returncode>{{returncode}}</returncode>
76
+ <output>
77
+ {{output}}
78
+ </output>`;
79
+
80
+ /** Build the full system prompt for a given mode ("autonomous" | "interactive"). */
81
+ export function buildSystemPrompt(mode, vars) {
82
+ const rules = mode === "interactive" ? INTERACTIVE_RULES : AUTONOMOUS_RULES;
83
+ return render(BASE_SYSTEM + "\n" + rules, vars);
84
+ }
85
+
86
+ /** Minimal, safe {{var}} substitution (no logic, no eval). */
87
+ export function render(template, vars) {
88
+ return template.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_, key) =>
89
+ key in vars && vars[key] != null ? String(vars[key]) : ""
90
+ );
91
+ }