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.
- package/dist/errors.d.ts +45 -0
- package/dist/errors.js +74 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +24 -1
- package/dist/solver.js +12 -0
- package/package.json +1 -1
- package/python/pyproject.toml +1 -1
- package/python/src/captchakraken/cli.py +12 -0
- package/python/src/captchakraken/config.py +80 -12
- package/python/src/captchakraken/errors.py +195 -0
- package/python/src/captchakraken/planner.py +76 -15
package/dist/errors.d.ts
ADDED
|
@@ -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
|
+
"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",
|
package/python/pyproject.toml
CHANGED
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "captchakraken"
|
|
7
|
-
version = "2.
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
127
|
+
if name not in _KEY_NAMES and name not in _BASE_URL_NAMES:
|
|
88
128
|
continue
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
228
|
-
# resp.json() blow up with a cryptic "Expecting
|
|
229
|
-
# on a non-JSON response
|
|
230
|
-
#
|
|
231
|
-
#
|
|
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
|
-
|
|
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()
|