captchakraken 2.3.0 → 2.4.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,45 @@
1
+ /**
2
+ * Hosted-API refusals, carried across the Python subprocess boundary.
3
+ *
4
+ * The TypeScript driver does not talk to the model; it shells out to the Python
5
+ * CLI, which does. So every hosted-API error arrives here as text on stderr and
6
+ * would, without this, be flattened into
7
+ * `Failed to execute captcha solver CLI: Command failed with exit code 3` —
8
+ * a sentence that tells a camoufox user nothing about the fact that their
9
+ * account is out of credits.
10
+ *
11
+ * The CLI exits 3 and prints `{"error": "...", "ck_error": {...}}`, so the
12
+ * wording is decided once, in Python (`captchakraken/errors.py`), and merely
13
+ * relayed here. Deliberately NOT re-worded on this side: two copies of the same
14
+ * message drift, and the Python one is also what a direct CLI user sees.
15
+ */
16
+ /** The machine-readable half. Mirrors the gateway's `GatewayErrorCode`. */
17
+ export type CaptchaKrakenErrorCode = 'missing_api_key' | 'invalid_api_key' | 'account_suspended' | 'insufficient_credits' | 'rate_limited' | 'solve_abandoned' | 'unrecognized_prompt' | 'invalid_request' | 'request_too_large' | 'upstream_unavailable' | (string & {});
18
+ export declare class CaptchaKrakenAPIError extends Error {
19
+ readonly status: number | undefined;
20
+ readonly code: CaptchaKrakenErrorCode | undefined;
21
+ /** Where to send the user to fix it, when the server named somewhere. */
22
+ readonly resolutionUrl: string | undefined;
23
+ /** Seconds to wait, on `rate_limited`. */
24
+ readonly retryAfterSeconds: number | undefined;
25
+ /** Lets `catch` blocks branch without `instanceof` across module copies. */
26
+ readonly isCaptchaKrakenAPIError = true;
27
+ constructor(message: string, fields?: {
28
+ status?: number;
29
+ code?: string;
30
+ resolutionUrl?: string;
31
+ retryAfterSeconds?: number;
32
+ });
33
+ }
34
+ /**
35
+ * Pull a hosted-API error out of the CLI's stderr, or return null.
36
+ *
37
+ * Scans line by line rather than parsing the whole buffer: stderr also carries
38
+ * timing records and any warning the Python side emitted, so the JSON payload
39
+ * is one line among several and `JSON.parse(stderr)` would simply fail.
40
+ *
41
+ * Returns null for anything that is not recognisably ours, which leaves the
42
+ * caller's existing generic handling in place — an unparseable stderr must not
43
+ * become a confident but wrong billing message.
44
+ */
45
+ export declare function parseApiError(stderr: string): CaptchaKrakenAPIError | null;
package/dist/errors.js ADDED
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ /**
3
+ * Hosted-API refusals, carried across the Python subprocess boundary.
4
+ *
5
+ * The TypeScript driver does not talk to the model; it shells out to the Python
6
+ * CLI, which does. So every hosted-API error arrives here as text on stderr and
7
+ * would, without this, be flattened into
8
+ * `Failed to execute captcha solver CLI: Command failed with exit code 3` —
9
+ * a sentence that tells a camoufox user nothing about the fact that their
10
+ * account is out of credits.
11
+ *
12
+ * The CLI exits 3 and prints `{"error": "...", "ck_error": {...}}`, so the
13
+ * wording is decided once, in Python (`captchakraken/errors.py`), and merely
14
+ * relayed here. Deliberately NOT re-worded on this side: two copies of the same
15
+ * message drift, and the Python one is also what a direct CLI user sees.
16
+ */
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.CaptchaKrakenAPIError = void 0;
19
+ exports.parseApiError = parseApiError;
20
+ class CaptchaKrakenAPIError extends Error {
21
+ constructor(message, fields = {}) {
22
+ super(message);
23
+ /** Lets `catch` blocks branch without `instanceof` across module copies. */
24
+ this.isCaptchaKrakenAPIError = true;
25
+ this.name = 'CaptchaKrakenAPIError';
26
+ this.status = fields.status;
27
+ this.code = fields.code;
28
+ this.resolutionUrl = fields.resolutionUrl;
29
+ this.retryAfterSeconds = fields.retryAfterSeconds;
30
+ }
31
+ }
32
+ exports.CaptchaKrakenAPIError = CaptchaKrakenAPIError;
33
+ /**
34
+ * Pull a hosted-API error out of the CLI's stderr, or return null.
35
+ *
36
+ * Scans line by line rather than parsing the whole buffer: stderr also carries
37
+ * timing records and any warning the Python side emitted, so the JSON payload
38
+ * is one line among several and `JSON.parse(stderr)` would simply fail.
39
+ *
40
+ * Returns null for anything that is not recognisably ours, which leaves the
41
+ * caller's existing generic handling in place — an unparseable stderr must not
42
+ * become a confident but wrong billing message.
43
+ */
44
+ function parseApiError(stderr) {
45
+ if (!stderr || !stderr.includes('ck_error'))
46
+ return null;
47
+ for (const line of stderr.split('\n')) {
48
+ const trimmed = line.trim();
49
+ if (!trimmed.startsWith('{') || !trimmed.includes('ck_error'))
50
+ continue;
51
+ let parsed;
52
+ try {
53
+ parsed = JSON.parse(trimmed);
54
+ }
55
+ catch {
56
+ continue;
57
+ }
58
+ const details = parsed?.ck_error;
59
+ if (!details || typeof details !== 'object')
60
+ continue;
61
+ const message = typeof parsed.error === 'string' && parsed.error.trim()
62
+ ? parsed.error
63
+ : 'CaptchaKraken refused this solve.';
64
+ return new CaptchaKrakenAPIError(message, {
65
+ status: typeof details.status === 'number' ? details.status : undefined,
66
+ code: typeof details.code === 'string' ? details.code : undefined,
67
+ resolutionUrl: typeof details.resolution_url === 'string' ? details.resolution_url : undefined,
68
+ retryAfterSeconds: typeof details.retry_after_seconds === 'number'
69
+ ? details.retry_after_seconds
70
+ : undefined,
71
+ });
72
+ }
73
+ return null;
74
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,28 @@
1
1
  export * from './types';
2
2
  export { CaptchaKrakenSolver } from './solver';
3
+ /**
4
+ * Errors raised when the **hosted** CaptchaKraken API refuses a solve — out of
5
+ * credits, rate limited, key rejected, attempt abandoned.
6
+ *
7
+ * `message` is already a complete sentence naming the product and, where one
8
+ * exists, the URL that fixes it, so printing it is enough. `code` is the stable
9
+ * contract to branch on; the prose is not.
10
+ *
11
+ * @example
12
+ * ```typescript
13
+ * import { CaptchaKrakenSolver, CaptchaKrakenAPIError } from 'captchakraken';
14
+ *
15
+ * try {
16
+ * await solver.solve(page);
17
+ * } catch (e) {
18
+ * if (e instanceof CaptchaKrakenAPIError && e.code === 'insufficient_credits') {
19
+ * console.error(`Top up: ${e.resolutionUrl}`);
20
+ * } else throw e;
21
+ * }
22
+ * ```
23
+ */
24
+ export { CaptchaKrakenAPIError } from './errors';
25
+ export type { CaptchaKrakenErrorCode } from './errors';
3
26
  /**
4
27
  * Adapter for driving the solver with **Puppeteer** instead of a Playwright
5
28
  * launcher. Puppeteer's `Page` is API-compatible with Playwright's except for a
package/dist/index.js CHANGED
@@ -14,10 +14,33 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.fromPuppeteer = exports.CaptchaKrakenSolver = void 0;
17
+ exports.fromPuppeteer = exports.CaptchaKrakenAPIError = exports.CaptchaKrakenSolver = void 0;
18
18
  __exportStar(require("./types"), exports);
19
19
  var solver_1 = require("./solver");
20
20
  Object.defineProperty(exports, "CaptchaKrakenSolver", { enumerable: true, get: function () { return solver_1.CaptchaKrakenSolver; } });
21
+ /**
22
+ * Errors raised when the **hosted** CaptchaKraken API refuses a solve — out of
23
+ * credits, rate limited, key rejected, attempt abandoned.
24
+ *
25
+ * `message` is already a complete sentence naming the product and, where one
26
+ * exists, the URL that fixes it, so printing it is enough. `code` is the stable
27
+ * contract to branch on; the prose is not.
28
+ *
29
+ * @example
30
+ * ```typescript
31
+ * import { CaptchaKrakenSolver, CaptchaKrakenAPIError } from 'captchakraken';
32
+ *
33
+ * try {
34
+ * await solver.solve(page);
35
+ * } catch (e) {
36
+ * if (e instanceof CaptchaKrakenAPIError && e.code === 'insufficient_credits') {
37
+ * console.error(`Top up: ${e.resolutionUrl}`);
38
+ * } else throw e;
39
+ * }
40
+ * ```
41
+ */
42
+ var errors_1 = require("./errors");
43
+ Object.defineProperty(exports, "CaptchaKrakenAPIError", { enumerable: true, get: function () { return errors_1.CaptchaKrakenAPIError; } });
21
44
  /**
22
45
  * Adapter for driving the solver with **Puppeteer** instead of a Playwright
23
46
  * launcher. Puppeteer's `Page` is API-compatible with Playwright's except for a
package/dist/solver.js CHANGED
@@ -42,6 +42,7 @@ const path = __importStar(require("path"));
42
42
  const os = __importStar(require("os"));
43
43
  const crypto_1 = require("crypto");
44
44
  const token_usage_1 = require("./token-usage");
45
+ const errors_1 = require("./errors");
45
46
  const execAsync = (0, util_1.promisify)(child_process_1.exec);
46
47
  const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
47
48
  function getBundledCliRoot() {
@@ -1865,6 +1866,17 @@ class CaptchaKrakenSolver {
1865
1866
  e.unsupported = true;
1866
1867
  throw e;
1867
1868
  }
1869
+ // The hosted API refused the solve and said why (out of credits, rate
1870
+ // limited, attempt abandoned…). The CLI exits 3 with the already-worded
1871
+ // sentence plus the machine-readable fields; both are rethrown as-is.
1872
+ //
1873
+ // Wrapping this in "Failed to execute captcha solver CLI: Command failed
1874
+ // …" — which is what happens to every other non-zero exit — would bury a
1875
+ // billing problem under a sentence about a subprocess. camoufox surfaces
1876
+ // whatever we throw, so this is the message its users actually read.
1877
+ const apiError = (0, errors_1.parseApiError)(stderr);
1878
+ if (apiError)
1879
+ throw apiError;
1868
1880
  console.error('Error executing CaptchaKraken CLI:', error);
1869
1881
  if (error.stdout)
1870
1882
  console.log('CLI stdout on error:', error.stdout);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "captchakraken",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "Browser-agnostic Playwright/Puppeteer captcha solver. Detects the captcha, reads the grid with a fine-tuned Qwen3.5-9B vision model on vLLM, and clicks through to a token.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "captchakraken"
7
- version = "2.3.0"
7
+ version = "2.4.0"
8
8
  description = "Self-hosted captcha solver: OpenCV grid detection + a fine-tuned Qwen3.5-9B vision LoRA served on vLLM."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -56,6 +56,7 @@ import os
56
56
  import subprocess
57
57
  import sys
58
58
 
59
+ from .errors import CaptchaKrakenAPIError
59
60
  from .solver import CaptchaSolver, UnsupportedCaptchaError
60
61
  from .timing import timed
61
62
 
@@ -707,6 +708,17 @@ def main():
707
708
  # checkboxes. Emit a clean error with no traceback.
708
709
  print(json.dumps({"error": str(e), "unsupported": True}), file=sys.stderr)
709
710
  sys.exit(2)
711
+ except CaptchaKrakenAPIError as e:
712
+ # The hosted API refused us and told us why. That is an answer, not a
713
+ # crash, so no traceback: a stack trace here buries the one line the
714
+ # user needs ("out of credits, top up at …") under frames from a module
715
+ # they have never opened.
716
+ #
717
+ # Exit 3, distinct from 1 (genuine failure) and 2 (unsupported puzzle),
718
+ # and the payload carries the machine-readable fields so the JS driver
719
+ # can rebuild the error rather than regex it out of prose.
720
+ print(json.dumps(e.to_payload()), file=sys.stderr)
721
+ sys.exit(3)
710
722
  except Exception as e:
711
723
  import traceback
712
724
 
@@ -47,8 +47,31 @@ def pinned() -> Dict[str, Any]:
47
47
 
48
48
  # ── Inference endpoint ──────────────────────────────────────────────────────
49
49
  def base_url() -> str:
50
- """OpenAI-compatible endpoint, e.g. http://localhost:8000/v1."""
51
- return os.getenv("VLLM_BASE_URL", f"http://localhost:{port()}/v1")
50
+ """OpenAI-compatible endpoint, e.g. http://localhost:8000/v1.
51
+
52
+ Precedence mirrors `api_key()` exactly — env, then the credentials file,
53
+ then the local default — because the two values are set together and a
54
+ reader who has learned one ordering should not have to learn a second.
55
+
56
+ 1. VLLM_BASE_URL. An explicit override always wins; self-hosters and the
57
+ dev environment depend on this and must not be silently redirected.
58
+ 2. The credentials file. Written by the MCP signup flow alongside the key,
59
+ so onboarding configures the endpoint in the same step and a hosted
60
+ user needs no env plumbing at all.
61
+ 3. localhost. Unchanged, and still correct: someone with no credentials
62
+ file is by definition not a hosted user, so the self-hosted default is
63
+ the right guess for them.
64
+
65
+ The bug this closes: a camoufox user who signed up through the MCP had a
66
+ valid key and no endpoint, so every solve dialled a local port with nothing
67
+ behind it and failed with connection-refused — a message that says nothing
68
+ about the fact that their requests were meant to go to api.captchakraken.com.
69
+ """
70
+ return (
71
+ os.getenv("VLLM_BASE_URL")
72
+ or _base_url_from_credentials_file()
73
+ or f"http://localhost:{port()}/v1"
74
+ )
52
75
 
53
76
 
54
77
  def state_dir() -> Path:
@@ -60,38 +83,83 @@ def credentials_path() -> Path:
60
83
  return state_dir() / "credentials"
61
84
 
62
85
 
63
- def _key_from_credentials_file() -> str:
64
- """Key written by the MCP server's signup flow (or a future `login` command).
86
+ # Names the credentials file may use for each value. Two spellings each, because
87
+ # the file doubles as something a user can `source` — the VLLM_* names are the
88
+ # env vars this module already honours, and the CAPTCHA_KRAKEN_* names are what
89
+ # the product calls them. Accepting both costs nothing and spares anyone the
90
+ # discovery that they picked the wrong one.
91
+ _KEY_NAMES = ("CAPTCHA_KRAKEN_API_KEY", "VLLM_API_KEY")
92
+ _BASE_URL_NAMES = ("CAPTCHA_KRAKEN_BASE_URL", "VLLM_BASE_URL")
93
+
94
+
95
+ def _read_credentials_file() -> Dict[str, str]:
96
+ """Parse the file the MCP server's signup flow writes.
65
97
 
66
98
  Hosted-API onboarding writes the key to a 0600 file rather than returning it
67
99
  through the agent transcript, so it never lands in an LLM context window.
68
100
  Reading it here is what lets a solve authenticate with NO env vars set.
69
101
 
70
- Deliberately tolerant of a bare token, an env-file line, or surrounding
71
- quotes: the file is written by a *separate* tool, and a format mismatch here
72
- would surface as a baffling 401 rather than an obvious parse error.
73
- Returns "" whenever the file is missing, unreadable, or has no usable line.
102
+ Returns a mapping of recognised name -> value. The special key ``""`` holds a
103
+ BARE TOKEN — a file containing nothing but `ck_live_…`, which is what the
104
+ original single-value format was and what a user hand-writing this file is
105
+ most likely to produce. Dropping that would silently break every existing
106
+ credentials file, so it is still accepted and still means "the API key".
107
+
108
+ Deliberately tolerant of quoting and comments: the file is written by a
109
+ *separate* tool, and a format mismatch here surfaces as a baffling 401
110
+ rather than an obvious parse error. Returns {} whenever the file is missing
111
+ or unreadable — the self-hosted case, which must stay silent.
74
112
  """
75
113
  try:
76
114
  text = credentials_path().read_text(encoding="utf-8")
77
115
  except OSError:
78
- return ""
116
+ return {}
79
117
 
118
+ found: Dict[str, str] = {}
80
119
  for line in text.splitlines():
81
120
  line = line.strip()
82
121
  if not line or line.startswith("#"):
83
122
  continue
84
123
  if "=" in line:
85
124
  name, _, value = line.partition("=")
125
+ name = name.strip()
86
126
  # An env-file with unrelated keys must not yield a bogus token.
87
- if name.strip() not in {"CAPTCHA_KRAKEN_API_KEY", "VLLM_API_KEY"}:
127
+ if name not in _KEY_NAMES and name not in _BASE_URL_NAMES:
88
128
  continue
89
- line = value.strip()
90
- return line.strip("'\"")
129
+ found.setdefault(name, value.strip().strip("'\""))
130
+ elif "" not in found:
131
+ # A bare token. Only the first such line counts; later prose in a
132
+ # hand-edited file must not overwrite the credential.
133
+ found[""] = line.strip("'\"")
134
+
135
+ return found
91
136
 
137
+
138
+ def _first_present(values: Dict[str, str], names) -> str:
139
+ """First non-empty value among `names`, in the order given."""
140
+ for name in names:
141
+ value = values.get(name, "").strip()
142
+ if value:
143
+ return value
92
144
  return ""
93
145
 
94
146
 
147
+ def _key_from_credentials_file() -> str:
148
+ """The API key from the credentials file, or "" if there isn't one."""
149
+ values = _read_credentials_file()
150
+ return _first_present(values, _KEY_NAMES) or values.get("", "").strip()
151
+
152
+
153
+ def _base_url_from_credentials_file() -> str:
154
+ """The endpoint from the credentials file, or "" if it names none.
155
+
156
+ A bare-token file deliberately yields nothing here: it carries no endpoint,
157
+ and inventing the hosted one for it would silently redirect a self-hoster
158
+ who wrote a token into that file by hand.
159
+ """
160
+ return _first_present(_read_credentials_file(), _BASE_URL_NAMES)
161
+
162
+
95
163
  def api_key() -> str:
96
164
  """Bearer token, in precedence order.
97
165
 
@@ -0,0 +1,195 @@
1
+ """What the hosted API's refusals mean, in words a user can act on.
2
+
3
+ The solver talks to an OpenAI-compatible endpoint that may be a local vLLM the
4
+ user started themselves or the hosted CaptchaKraken gateway. Those two fail very
5
+ differently, and until this module existed both were reported the same way:
6
+ `vLLM 402 Payment Required at https://api.captchakraken.com/v1/chat/completions`.
7
+ A camoufox user who has never heard of vLLM, run vLLM, or intended to run vLLM
8
+ would hit that on the day their credits ran out.
9
+
10
+ Two rules, both inherited from the gateway's own `src/errors.ts`:
11
+
12
+ 1. BRANCH ON `error.code`, NEVER ON PROSE. The messages get reworded; the codes
13
+ are the contract. A client that pattern-matches "Out of credits" breaks the
14
+ day someone improves the wording.
15
+
16
+ 2. AN UNRECOGNISED CODE MUST STILL PRODUCE A USEFUL MESSAGE. There are ten
17
+ codes today and there will be more. Enumerating them exhaustively here means
18
+ the eleventh is reported worse than the ten — so the fallback carries the
19
+ server's own `message` and `resolution_url` through, and only the phrasing
20
+ is lost.
21
+
22
+ The self-hosted path is deliberately untouched: a local vLLM does not send this
23
+ envelope, so `from_response` returns a message shaped like the old one and the
24
+ 401/403 hint about the bearer token survives. Nobody self-hosting sees a word
25
+ about credits.
26
+ """
27
+
28
+ from typing import Any, Dict, Optional
29
+
30
+ # Where a user with no `resolution_url` in hand should be sent. Only used as a
31
+ # fallback — the server almost always supplies a deep link that is better than
32
+ # this, and when it does we prefer it.
33
+ _DASHBOARD = "https://captchakraken.com/dashboard"
34
+ _SUPPORT = "https://captchakraken.com/support"
35
+
36
+
37
+ class CaptchaKrakenAPIError(RuntimeError):
38
+ """A refusal from the hosted API, already translated.
39
+
40
+ Carries the machine-readable parts alongside the message so a caller that
41
+ wants to react programmatically (retry on `rate_limited`, stop on
42
+ `insufficient_credits`) can do so without re-parsing anything. `str(e)` is
43
+ the human sentence and is what reaches a console.
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ message: str,
49
+ *,
50
+ status: Optional[int] = None,
51
+ code: Optional[str] = None,
52
+ resolution_url: Optional[str] = None,
53
+ retry_after_seconds: Optional[float] = None,
54
+ ):
55
+ super().__init__(message)
56
+ self.status = status
57
+ self.code = code
58
+ self.resolution_url = resolution_url
59
+ self.retry_after_seconds = retry_after_seconds
60
+
61
+ def to_payload(self) -> Dict[str, Any]:
62
+ """The shape the CLI puts on stderr for the JS driver to pick up.
63
+
64
+ The JS half shells out to this CLI and can only see stdout/stderr, so
65
+ the structured fields would otherwise be flattened to a string at the
66
+ process boundary and have to be re-parsed out of prose — exactly the
67
+ thing rule 1 forbids.
68
+ """
69
+ return {
70
+ "error": str(self),
71
+ "ck_error": {
72
+ "status": self.status,
73
+ "code": self.code,
74
+ "resolution_url": self.resolution_url,
75
+ "retry_after_seconds": self.retry_after_seconds,
76
+ },
77
+ }
78
+
79
+
80
+ def _retry_after(headers: Any) -> Optional[float]:
81
+ """Seconds from the `Retry-After` header, if it is the numeric form.
82
+
83
+ HTTP also permits an HTTP-date here. The gateway only ever sends deltas, and
84
+ guessing wrong about a date would produce a confidently incorrect "wait 3
85
+ seconds", so anything unparseable is dropped rather than approximated.
86
+ """
87
+ try:
88
+ raw = headers.get("Retry-After")
89
+ except Exception: # noqa: BLE001 — a mapping-ish object is all we assume
90
+ return None
91
+ if raw is None:
92
+ return None
93
+ try:
94
+ return float(str(raw).strip())
95
+ except ValueError:
96
+ return None
97
+
98
+
99
+ def _sentence(code: str, message: str, url: Optional[str], retry: Optional[float]) -> str:
100
+ """The user-facing line for a known code.
101
+
102
+ Every branch names CaptchaKraken. That is the entire point of this module:
103
+ the person reading it needs to know which product is refusing them before
104
+ anything else in the sentence can help.
105
+ """
106
+ if code == "insufficient_credits":
107
+ return (
108
+ "CaptchaKraken: your account is out of credits, so this solve was refused. "
109
+ f"Top up at {url or _DASHBOARD} and retry."
110
+ )
111
+
112
+ if code == "solve_abandoned":
113
+ return (
114
+ "CaptchaKraken: this captcha attempt was served too many times without "
115
+ "settling and has been abandoned. That usually means the IP reputation or "
116
+ "the browser fingerprint is being rejected — not that the answers were "
117
+ "wrong. Start a fresh attempt with a new x-ck-session (the driver mints "
118
+ "one per solve() call, so a new solve is enough)."
119
+ )
120
+
121
+ if code == "rate_limited":
122
+ wait = f" Retry in about {retry:g}s." if retry else " Back off and retry."
123
+ return f"CaptchaKraken: too many requests.{wait}"
124
+
125
+ if code == "account_suspended":
126
+ return (
127
+ "CaptchaKraken: this account is suspended, so solving is disabled. "
128
+ f"Contact support at {url or _SUPPORT}."
129
+ )
130
+
131
+ if code == "request_too_large":
132
+ return (
133
+ "CaptchaKraken: the screenshot sent for this solve exceeded the request "
134
+ f"size limit. ({message}) Capture the captcha element rather than the "
135
+ "whole page if you are not already."
136
+ )
137
+
138
+ if code in ("missing_api_key", "invalid_api_key"):
139
+ return (
140
+ "CaptchaKraken: the API key was missing or not accepted. Set "
141
+ "CAPTCHA_KRAKEN_API_KEY, or run the CaptchaKraken MCP server's "
142
+ "`create_api_key` tool to write one to ~/.captchakraken/credentials. "
143
+ f"Manage keys at {url or _DASHBOARD}."
144
+ )
145
+
146
+ if code == "upstream_unavailable":
147
+ return (
148
+ "CaptchaKraken: the solver fleet is temporarily unreachable. This is on "
149
+ "our side, not yours — retry shortly."
150
+ )
151
+
152
+ # `unrecognized_prompt`, `invalid_request`, and anything added after this was
153
+ # written. The server's own message is the best thing available, so it is
154
+ # carried through verbatim rather than replaced with a guess.
155
+ tail = f" See {url}." if url else ""
156
+ return f"CaptchaKraken: {message}{tail}"
157
+
158
+
159
+ def from_response(resp: Any, url: str) -> Exception:
160
+ """Build the exception for a non-OK response, hosted or self-hosted.
161
+
162
+ Returns rather than raises so the caller's `raise` keeps the traceback
163
+ anchored at the request site, where it is useful.
164
+ """
165
+ body_text = (getattr(resp, "text", "") or "")[:300]
166
+ status = getattr(resp, "status_code", None)
167
+
168
+ error: Dict[str, Any] = {}
169
+ try:
170
+ parsed = resp.json()
171
+ if isinstance(parsed, dict) and isinstance(parsed.get("error"), dict):
172
+ error = parsed["error"]
173
+ except Exception: # noqa: BLE001 — a non-JSON body is the self-hosted case
174
+ error = {}
175
+
176
+ code = error.get("code")
177
+ if isinstance(code, str) and code:
178
+ retry = _retry_after(getattr(resp, "headers", {}) or {})
179
+ resolution = error.get("resolution_url") or None
180
+ message = str(error.get("message") or "").strip() or "the request was refused"
181
+ return CaptchaKrakenAPIError(
182
+ _sentence(code, message, resolution, retry),
183
+ status=status,
184
+ code=code,
185
+ resolution_url=resolution,
186
+ retry_after_seconds=retry,
187
+ )
188
+
189
+ # No gateway envelope: a local vLLM, a proxy in between, or an HTML error
190
+ # page. Keep the pre-existing message so self-hosted debugging is unchanged.
191
+ hint = ""
192
+ if status in (401, 403):
193
+ hint = " — check CAPTCHA_KRAKEN_API_KEY is set and forwarded to the CLI"
194
+ reason = getattr(resp, "reason", "") or ""
195
+ return RuntimeError(f"vLLM {status} {reason} at {url}{hint}. Body: {body_text}")
@@ -17,7 +17,7 @@ from typing import Any, Dict, List, Optional
17
17
 
18
18
  import requests
19
19
 
20
- from . import config
20
+ from . import config, errors
21
21
  from .server_manager import ensure_server
22
22
  from .timing import timed
23
23
 
@@ -55,17 +55,79 @@ _CLIENT_ENV = "CAPTCHA_KRAKEN_CLIENT"
55
55
  _SESSION_HEADER = "X-CK-Session"
56
56
  _SESSION_ENV = "CAPTCHA_KRAKEN_SESSION"
57
57
 
58
+ # Arbitrary extra headers, as "Name: value" pairs separated by newlines or
59
+ # commas. Empty and absent by default.
60
+ #
61
+ # WHY THIS EXISTS. Some endpoints sit behind a gate that is not the API key —
62
+ # our own dev gateway (api.dev.captchakraken.com) wants X-CK-Dev-Auth, and a
63
+ # corporate egress proxy may want its own token. Without this the published
64
+ # client simply cannot reach such an endpoint, which meant the dev environment
65
+ # could not exercise the real client path at all: the thing dev exists to
66
+ # rehearse was the one thing it could not do.
67
+ #
68
+ # IT CANNOT OVERWRITE Authorization, Content-Type, OR THE X-CK-* HEADERS. Those
69
+ # carry the credential and the billing attribution. Allowing an env var to
70
+ # rewrite Authorization would turn a stray export into "your key is not the key
71
+ # being charged", and allowing it to rewrite X-CK-Session would let a caller
72
+ # escape the per-attempt billing cap by pinning one session id forever.
73
+ _EXTRA_HEADERS_ENV = "CAPTCHA_KRAKEN_EXTRA_HEADERS"
74
+ _PROTECTED_HEADERS = frozenset(
75
+ {"authorization", "content-type", _CLIENT_HEADER.lower(), _SESSION_HEADER.lower()}
76
+ )
77
+
58
78
  # These values reach the wire verbatim from the environment, so they are
59
79
  # sanitized rather than trusted: a CR/LF would otherwise splice arbitrary
60
80
  # headers into the upstream request.
61
81
  _HEADER_VALUE_MAX = 128
62
82
 
63
83
 
84
+ # RFC 7230 token characters. Anything else is not a header name.
85
+ _VALID_HEADER_NAME = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$")
86
+
87
+
64
88
  def _clean_header_value(raw: str) -> str:
65
89
  """Printable-ASCII, length-capped header value ("" when nothing survives)."""
66
90
  return "".join(c for c in raw.strip() if 0x20 <= ord(c) < 0x7F)[:_HEADER_VALUE_MAX]
67
91
 
68
92
 
93
+ def _extra_headers(raw: str) -> Dict[str, str]:
94
+ """Parse CAPTCHA_KRAKEN_EXTRA_HEADERS into a header dict.
95
+
96
+ Accepts "Name: value" pairs separated by newlines or commas:
97
+
98
+ CAPTCHA_KRAKEN_EXTRA_HEADERS='X-CK-Dev-Auth: hunter2'
99
+ CAPTCHA_KRAKEN_EXTRA_HEADERS=$'X-One: a\nX-Two: b'
100
+
101
+ Malformed entries are DROPPED, never guessed at. A pair with no colon is
102
+ not a header, and inventing one from it would put an attacker-shaped string
103
+ on the wire. Names are restricted to the RFC 7230 token characters for the
104
+ same reason the values are sanitized: a CR/LF in either would splice extra
105
+ headers into the request.
106
+
107
+ Protected headers are refused, so this cannot be used to replace the
108
+ credential or the billing attribution.
109
+ """
110
+ out: Dict[str, str] = {}
111
+ if not raw.strip():
112
+ return out
113
+
114
+ for entry in raw.replace(",", "\n").splitlines():
115
+ entry = entry.strip()
116
+ if not entry or ":" not in entry:
117
+ continue
118
+ name, _, value = entry.partition(":")
119
+ name = name.strip()
120
+ value = _clean_header_value(value)
121
+ if not name or not value:
122
+ continue
123
+ if not _VALID_HEADER_NAME.match(name):
124
+ continue
125
+ if name.lower() in _PROTECTED_HEADERS:
126
+ continue
127
+ out[name] = value
128
+ return out
129
+
130
+
69
131
  def routing_headers(env=None) -> Dict[str, str]:
70
132
  """Fleet-routing + hosted-API headers derived from the environment.
71
133
 
@@ -93,6 +155,8 @@ def routing_headers(env=None) -> Dict[str, str]:
93
155
  if value:
94
156
  headers[header] = value
95
157
 
158
+ headers.update(_extra_headers(env.get(_EXTRA_HEADERS_ENV) or ""))
159
+
96
160
  return headers
97
161
 
98
162
 
@@ -224,21 +288,18 @@ class ActionPlanner:
224
288
  with timed("planner.chat"):
225
289
  resp = requests.post(url, headers=headers, json=payload, timeout=120)
226
290
 
227
- # Surface auth / server errors with the actual body instead of letting
228
- # resp.json() blow up with a cryptic "Expecting value: line 1 column 1"
229
- # on a non-JSON response (e.g. a 401 {"error":"Unauthorized"} or an
230
- # HTML error page). 401/403 almost always means the bearer token
231
- # (CAPTCHA_KRAKEN_API_KEY) didn't reach this process.
291
+ # Surface auth / billing / server errors as something the reader can act
292
+ # on, instead of letting resp.json() blow up with a cryptic "Expecting
293
+ # value: line 1 column 1" on a non-JSON response.
294
+ #
295
+ # `errors.from_response` decides which of the two worlds we are in by
296
+ # looking for the gateway's `error.code`: present means the hosted API
297
+ # refused us and there is a real explanation to give (out of credits,
298
+ # rate limited, attempt abandoned); absent means a local vLLM or a proxy,
299
+ # and the old message — including the bearer-token hint on 401/403 — is
300
+ # reproduced unchanged.
232
301
  if not resp.ok:
233
- body = (resp.text or "")[:300]
234
- hint = ""
235
- if resp.status_code in (401, 403):
236
- hint = (
237
- " — check CAPTCHA_KRAKEN_API_KEY is set and forwarded to the CLI"
238
- )
239
- raise RuntimeError(
240
- f"vLLM {resp.status_code} {resp.reason} at {url}{hint}. Body: {body}"
241
- )
302
+ raise errors.from_response(resp, url)
242
303
 
243
304
  try:
244
305
  data = resp.json()