codex-pdf 1.4.2__tar.gz → 1.4.4__tar.gz
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.
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/PKG-INFO +1 -1
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/clients/ts/package.json +1 -1
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/clients/ts/src/index.test.ts +46 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/clients/ts/src/index.ts +264 -52
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/docs/operations/codex-change-ripple.md +14 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/pyproject.toml +1 -1
- codex_pdf-1.4.4/reports/dieline_calibration_report.json +10 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/schemas/v1/codex-document.schema.json +177 -0
- codex_pdf-1.4.4/scripts/calibrate_dieline_heuristics.py +92 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/client/http_client.py +272 -24
- codex_pdf-1.4.2/src/codex_pdf/extract/summary.py → codex_pdf-1.4.4/src/codex_pdf/extract/dieline_detector.py +257 -270
- codex_pdf-1.4.4/src/codex_pdf/extract/summary.py +277 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/models/v1.py +31 -1
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/conftest.py +7 -0
- codex_pdf-1.4.4/tests/test_client_routing.py +102 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/test_summary_dieline.py +82 -0
- codex_pdf-1.4.4/tests/test_summary_spot_colors.py +58 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/uv.lock +1 -1
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/.cursor/rules/service-ownership.mdc +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/.github/workflows/ci.yml +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/.gitignore +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/.windsurf/rules/service-ownership.md +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/APPROVALS.md +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/CLAUDE.md +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/Dockerfile +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/LICENSE +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/Procfile +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/README.md +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/clients/ts/README.md +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/clients/ts/package-lock.json +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/clients/ts/src/color.ts +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/clients/ts/tsconfig.json +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/docs/architecture.md +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/docs/backward-compatibility.md +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/docs/cleanup-stop-gates.md +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/docs/cli.md +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/docs/contract.md +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/docs/deploy.md +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/docs/discovery-audit.md +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/docs/migration-plan.md +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/docs/operations/marketing-deploy-template.md +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/docs/parity.md +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/docs/preflight-ingest.md +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/docs/release-1.0.0.md +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/docs/service-ownership-contract.md +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/railway.toml +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/reports/audit/mislocated-closure.json +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/reports/audit/produce_surface.json +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/reports/parity/codex_deep.json +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/reports/parity/codex_inventory.json +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/reports/parity/codex_summary.json +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/reports/parity/criterion4_parser_surface.json +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/reports/parity/pdfx4_deep.json +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/reports/parity/pdfx4_inventory.json +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/reports/parity/pdfx4_summary.json +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/reports/parity/render_baseline.json +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/reports/parity/viewer_essentials.json +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/schemas/CHANGELOG.md +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/schemas/v1/codex-annotation.schema.json +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/schemas/v1/codex-box.schema.json +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/schemas/v1/codex-color-space.schema.json +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/schemas/v1/codex-font.schema.json +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/schemas/v1/codex-form-xobject.schema.json +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/schemas/v1/codex-image.schema.json +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/schemas/v1/codex-issue.schema.json +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/schemas/v1/codex-ocg.schema.json +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/schemas/v1/codex-output-intent.schema.json +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/schemas/v1/codex-page-object.schema.json +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/schemas/v1/codex-page.schema.json +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/schemas/v1/codex-preflight-report.schema.json +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/schemas/v1/codex-source.schema.json +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/schemas/v1/codex-spot-colorant.schema.json +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/schemas/v1/codex-transparency-tree.schema.json +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/schemas/v1/codex-trap-evidence.schema.json +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/schemas/v1/codex-warning.schema.json +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/scripts/parity_viewer_essentials.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/scripts/produce_surface_audit.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/__init__.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/api/__init__.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/api/auth.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/api/cache.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/api/main.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/api/url_ingest.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/cli.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/client/__init__.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/color/__init__.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/color/color_math.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/color/curated.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/color/data/pantone_reference.json +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/color/normalize.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/color/pantone.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/color/resolver.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/eval/__init__.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/eval/ps_type4.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/extract/__init__.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/extract/annotations.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/extract/color.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/extract/common.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/extract/content_inventory.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/extract/document.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/extract/fonts.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/extract/forms.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/extract/images.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/extract/ocg.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/extract/signals.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/extract/structure.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/extract/transparency.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/extract/trapping.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/geom/__init__.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/geom/box.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/geom/matrix.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/geom/path.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/geom/tile.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/geom/units.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/models/__init__.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/parity.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/preflight_ingest/__init__.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/preflight_ingest/adapters.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/render/__init__.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/render/_common.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/render/content_stream.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/render/layer.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/render/page.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/render/separations.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/schema.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/version.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/fixtures/conforming/minimal.pdf +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/fixtures/generate_fixtures.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/fixtures/violating/no_output_intent.pdf +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/fixtures/violating/no_trim_box.pdf +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/fixtures/violating/no_xmp.pdf +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/fixtures/violating/pdf_1_4.pdf +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/golden/1.0.0/reference.json +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/test_api.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/test_cache.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/test_cli_contract.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/test_color.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/test_extract_analysis_signals.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/test_extract_structural.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/test_geom.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/test_golden.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/test_golden_corpus.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/test_models.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/test_parity.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/test_preflight_ingest.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/test_produce_surface_audit.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/test_schema.py +0 -0
- {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/test_schemas_all.py +0 -0
|
@@ -34,6 +34,52 @@ describe("HttpClient", () => {
|
|
|
34
34
|
expect(captured?.get("authorization")).toBe("Bearer tok");
|
|
35
35
|
expect(captured?.get("x-codex-key")).toBe("key");
|
|
36
36
|
expect(captured?.get("x-codex-internal")).toBe("int");
|
|
37
|
+
expect(captured?.get("x-codex-route-mode")).toBe("single");
|
|
38
|
+
expect(captured?.get("x-codex-request-id")).toBeTruthy();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("fails over between targets in hybrid mode", async () => {
|
|
42
|
+
const seen: string[] = [];
|
|
43
|
+
const fakeFetch: typeof fetch = async (url) => {
|
|
44
|
+
const u = String(url);
|
|
45
|
+
seen.push(u);
|
|
46
|
+
if (u === "https://a.example.com/v1/contract") {
|
|
47
|
+
return new Response(
|
|
48
|
+
JSON.stringify({
|
|
49
|
+
contract_name: "codex-document",
|
|
50
|
+
section_schema_versions: { color: "0.9.0", geom: "1.0.0" },
|
|
51
|
+
}),
|
|
52
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
if (u === "https://b.example.com/v1/contract") {
|
|
56
|
+
return new Response(
|
|
57
|
+
JSON.stringify({
|
|
58
|
+
contract_name: "codex-document",
|
|
59
|
+
section_schema_versions: { color: "1.0.0", geom: "1.0.0" },
|
|
60
|
+
}),
|
|
61
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
if (u === "https://b.example.com/v1/healthz") {
|
|
65
|
+
return new Response(
|
|
66
|
+
JSON.stringify({ status: "ok", version: "1.4.2", ghostscript: true }),
|
|
67
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
return new Response("not found", { status: 404 });
|
|
71
|
+
};
|
|
72
|
+
const client = new HttpClient({
|
|
73
|
+
baseUrls: ["https://a.example.com", "https://b.example.com"],
|
|
74
|
+
routeMode: "hybrid",
|
|
75
|
+
requiredSectionVersions: { color: "1.0.0" },
|
|
76
|
+
fetch: fakeFetch,
|
|
77
|
+
});
|
|
78
|
+
const health = await client.healthz();
|
|
79
|
+
expect(health.version).toBe("1.4.2");
|
|
80
|
+
expect(seen).toContain("https://a.example.com/v1/contract");
|
|
81
|
+
expect(seen).toContain("https://b.example.com/v1/contract");
|
|
82
|
+
expect(seen).toContain("https://b.example.com/v1/healthz");
|
|
37
83
|
});
|
|
38
84
|
|
|
39
85
|
it("retries 5xx then returns body", async () => {
|
|
@@ -28,6 +28,16 @@ export type { CmykQuad as ColorCmykQuad, LabTriplet as ColorLabTriplet, RgbTripl
|
|
|
28
28
|
export interface CodexClientOptions {
|
|
29
29
|
/** Base URL of the codex API, e.g. `https://codex.example.com`. */
|
|
30
30
|
baseUrl?: string;
|
|
31
|
+
/** Optional endpoint pool for multi-instance routing. */
|
|
32
|
+
baseUrls?: string[];
|
|
33
|
+
/** Optional plant/instance identifier (hybrid routing preference). */
|
|
34
|
+
plant?: string;
|
|
35
|
+
/** single | plant | failover | hybrid (default when pool >1). */
|
|
36
|
+
routeMode?: "single" | "plant" | "failover" | "hybrid";
|
|
37
|
+
/** Deterministic affinity key for stable target selection. */
|
|
38
|
+
affinityKey?: string;
|
|
39
|
+
/** Required section schema versions used to preflight failover targets. */
|
|
40
|
+
requiredSectionVersions?: Record<string, string>;
|
|
31
41
|
bearerToken?: string;
|
|
32
42
|
apiKey?: string;
|
|
33
43
|
internalToken?: string;
|
|
@@ -273,21 +283,37 @@ const DEFAULT_MAX_RETRIES = 3;
|
|
|
273
283
|
*/
|
|
274
284
|
export class HttpClient {
|
|
275
285
|
readonly baseUrl: string;
|
|
286
|
+
readonly targets: { baseUrl: string; plant?: string }[];
|
|
287
|
+
readonly plant?: string;
|
|
288
|
+
readonly routeMode: "single" | "plant" | "failover" | "hybrid";
|
|
289
|
+
readonly affinityKey?: string;
|
|
290
|
+
readonly requiredSectionVersions: Record<string, string>;
|
|
276
291
|
readonly bearerToken?: string;
|
|
277
292
|
readonly apiKey?: string;
|
|
278
293
|
readonly internalToken?: string;
|
|
279
294
|
readonly timeoutMs: number;
|
|
280
295
|
readonly maxRetries: number;
|
|
281
296
|
private readonly fetchImpl: typeof fetch;
|
|
297
|
+
private readonly contractCache: Map<string, Record<string, unknown>>;
|
|
282
298
|
|
|
283
299
|
constructor(opts: CodexClientOptions = {}) {
|
|
284
|
-
const
|
|
285
|
-
if (
|
|
300
|
+
const targets = this.loadTargets(opts);
|
|
301
|
+
if (targets.length === 0) {
|
|
286
302
|
throw new CodexClientError(
|
|
287
303
|
"CODEX_API_BASE is not configured. The TypeScript codex client requires HTTP mode.",
|
|
288
304
|
);
|
|
289
305
|
}
|
|
290
|
-
this.
|
|
306
|
+
this.targets = targets;
|
|
307
|
+
this.baseUrl = targets[0].baseUrl;
|
|
308
|
+
this.plant = opts.plant ?? envVar("CODEX_PLANT") ?? undefined;
|
|
309
|
+
this.routeMode =
|
|
310
|
+
opts.routeMode ??
|
|
311
|
+
((envVar("CODEX_ROUTE_MODE") as "single" | "plant" | "failover" | "hybrid" | undefined) ??
|
|
312
|
+
(targets.length > 1 ? "hybrid" : "single"));
|
|
313
|
+
this.affinityKey =
|
|
314
|
+
opts.affinityKey ?? envVar("CODEX_AFFINITY_KEY") ?? envVar("CODEX_PLANT_AFFINITY_KEY") ?? undefined;
|
|
315
|
+
this.requiredSectionVersions =
|
|
316
|
+
opts.requiredSectionVersions ?? this.loadRequiredSectionVersions();
|
|
291
317
|
this.bearerToken = opts.bearerToken ?? envVar("CODEX_BEARER_TOKEN");
|
|
292
318
|
this.apiKey = opts.apiKey ?? envVar("CODEX_API_KEY");
|
|
293
319
|
this.internalToken = opts.internalToken ?? envVar("CODEX_INTERNAL_TOKEN");
|
|
@@ -302,13 +328,23 @@ export class HttpClient {
|
|
|
302
328
|
);
|
|
303
329
|
}
|
|
304
330
|
this.fetchImpl = fetchImpl;
|
|
331
|
+
this.contractCache = new Map();
|
|
305
332
|
}
|
|
306
333
|
|
|
307
|
-
private headers(
|
|
334
|
+
private headers(
|
|
335
|
+
target: { baseUrl: string; plant?: string } | null,
|
|
336
|
+
requestId: string,
|
|
337
|
+
extra: Record<string, string> = {},
|
|
338
|
+
): Record<string, string> {
|
|
308
339
|
const out: Record<string, string> = {};
|
|
309
340
|
if (this.bearerToken) out["Authorization"] = `Bearer ${this.bearerToken}`;
|
|
310
341
|
if (this.apiKey) out["X-Codex-Key"] = this.apiKey;
|
|
311
342
|
if (this.internalToken) out["X-Codex-Internal"] = this.internalToken;
|
|
343
|
+
out["X-Codex-Route-Mode"] = this.routeMode;
|
|
344
|
+
out["X-Codex-Request-Id"] = requestId;
|
|
345
|
+
if (this.affinityKey) out["X-Codex-Affinity-Key"] = this.affinityKey;
|
|
346
|
+
const effectivePlant = this.plant ?? target?.plant;
|
|
347
|
+
if (effectivePlant) out["X-Codex-Plant"] = effectivePlant;
|
|
312
348
|
return { ...out, ...extra };
|
|
313
349
|
}
|
|
314
350
|
|
|
@@ -319,42 +355,48 @@ export class HttpClient {
|
|
|
319
355
|
contentType?: string,
|
|
320
356
|
): Promise<Response> {
|
|
321
357
|
let lastErr: unknown;
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
358
|
+
const requestId = this.newRequestId();
|
|
359
|
+
for (const target of this.orderedTargets()) {
|
|
325
360
|
try {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
361
|
+
await this.ensureContractCompatible(target, requestId);
|
|
362
|
+
} catch (err) {
|
|
363
|
+
lastErr = err;
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt += 1) {
|
|
367
|
+
const controller = new AbortController();
|
|
368
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
369
|
+
try {
|
|
370
|
+
const headers: Record<string, string> = this.headers(target, requestId, { Accept: accept });
|
|
371
|
+
if (contentType) headers["Content-Type"] = contentType;
|
|
372
|
+
const res = await this.fetchImpl(target.baseUrl + path, {
|
|
373
|
+
method: "POST",
|
|
374
|
+
headers,
|
|
375
|
+
body,
|
|
376
|
+
signal: controller.signal,
|
|
377
|
+
});
|
|
378
|
+
clearTimeout(timer);
|
|
379
|
+
if (res.ok) return res;
|
|
380
|
+
if (res.status === 408 || res.status === 429 || (res.status >= 500 && res.status < 600)) {
|
|
381
|
+
lastErr = new CodexClientError(`codex ${path} -> ${res.status}`, {
|
|
382
|
+
status: res.status,
|
|
383
|
+
});
|
|
384
|
+
await new Promise((r) => setTimeout(r, Math.min(2 ** attempt * 1000, 8000)));
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
const text = await res.text().catch(() => "");
|
|
388
|
+
throw new CodexClientError(`codex ${path} -> ${res.status}: ${text.slice(0, 1000)}`, {
|
|
341
389
|
status: res.status,
|
|
390
|
+
body: text,
|
|
342
391
|
});
|
|
392
|
+
} catch (err) {
|
|
393
|
+
clearTimeout(timer);
|
|
394
|
+
lastErr = err;
|
|
395
|
+
if (err instanceof CodexClientError && err.status >= 0 && err.status < 500) {
|
|
396
|
+
throw err;
|
|
397
|
+
}
|
|
343
398
|
await new Promise((r) => setTimeout(r, Math.min(2 ** attempt * 1000, 8000)));
|
|
344
|
-
continue;
|
|
345
399
|
}
|
|
346
|
-
const text = await res.text().catch(() => "");
|
|
347
|
-
throw new CodexClientError(`codex ${path} -> ${res.status}: ${text.slice(0, 1000)}`, {
|
|
348
|
-
status: res.status,
|
|
349
|
-
body: text,
|
|
350
|
-
});
|
|
351
|
-
} catch (err) {
|
|
352
|
-
clearTimeout(timer);
|
|
353
|
-
lastErr = err;
|
|
354
|
-
if (err instanceof CodexClientError && err.status >= 0 && err.status < 500) {
|
|
355
|
-
throw err;
|
|
356
|
-
}
|
|
357
|
-
await new Promise((r) => setTimeout(r, Math.min(2 ** attempt * 1000, 8000)));
|
|
358
400
|
}
|
|
359
401
|
}
|
|
360
402
|
if (lastErr instanceof Error) throw lastErr;
|
|
@@ -362,26 +404,45 @@ export class HttpClient {
|
|
|
362
404
|
}
|
|
363
405
|
|
|
364
406
|
private async get(path: string): Promise<Response> {
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
407
|
+
const requestId = this.newRequestId();
|
|
408
|
+
let lastErr: unknown;
|
|
409
|
+
for (const target of this.orderedTargets()) {
|
|
410
|
+
try {
|
|
411
|
+
await this.ensureContractCompatible(target, requestId);
|
|
412
|
+
} catch (err) {
|
|
413
|
+
lastErr = err;
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
const controller = new AbortController();
|
|
417
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
418
|
+
try {
|
|
419
|
+
const res = await this.fetchImpl(target.baseUrl + path, {
|
|
420
|
+
method: "GET",
|
|
421
|
+
headers: this.headers(target, requestId),
|
|
422
|
+
signal: controller.signal,
|
|
379
423
|
});
|
|
424
|
+
clearTimeout(timer);
|
|
425
|
+
if (!res.ok) {
|
|
426
|
+
const text = await res.text().catch(() => "");
|
|
427
|
+
if (res.status === 408 || res.status === 429 || (res.status >= 500 && res.status < 600)) {
|
|
428
|
+
lastErr = new CodexClientError(`codex ${path} -> ${res.status}`, {
|
|
429
|
+
status: res.status,
|
|
430
|
+
body: text,
|
|
431
|
+
});
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
throw new CodexClientError(`codex ${path} -> ${res.status}: ${text.slice(0, 1000)}`, {
|
|
435
|
+
status: res.status,
|
|
436
|
+
body: text,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
return res;
|
|
440
|
+
} finally {
|
|
441
|
+
clearTimeout(timer);
|
|
380
442
|
}
|
|
381
|
-
return res;
|
|
382
|
-
} finally {
|
|
383
|
-
clearTimeout(timer);
|
|
384
443
|
}
|
|
444
|
+
if (lastErr instanceof Error) throw lastErr;
|
|
445
|
+
throw new CodexClientError(`codex ${path} failed across all targets`);
|
|
385
446
|
}
|
|
386
447
|
|
|
387
448
|
private buildForm(
|
|
@@ -446,6 +507,157 @@ export class HttpClient {
|
|
|
446
507
|
};
|
|
447
508
|
}
|
|
448
509
|
|
|
510
|
+
private loadTargets(opts: CodexClientOptions): { baseUrl: string; plant?: string }[] {
|
|
511
|
+
const targets: { baseUrl: string; plant?: string }[] = [];
|
|
512
|
+
if (opts.baseUrl) {
|
|
513
|
+
targets.push({ baseUrl: opts.baseUrl.replace(/\/+$/, "") });
|
|
514
|
+
return targets;
|
|
515
|
+
}
|
|
516
|
+
if (opts.baseUrls && opts.baseUrls.length > 0) {
|
|
517
|
+
for (const url of opts.baseUrls) {
|
|
518
|
+
if (typeof url === "string" && url.trim()) {
|
|
519
|
+
targets.push({ baseUrl: url.trim().replace(/\/+$/, "") });
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
if (targets.length > 0) return targets;
|
|
523
|
+
}
|
|
524
|
+
const poolJson = envVar("CODEX_API_POOL_JSON");
|
|
525
|
+
if (poolJson) {
|
|
526
|
+
try {
|
|
527
|
+
const parsed = JSON.parse(poolJson);
|
|
528
|
+
if (Array.isArray(parsed)) {
|
|
529
|
+
for (const item of parsed) {
|
|
530
|
+
if (item && typeof item === "object") {
|
|
531
|
+
const raw = (item as { base_url?: string; url?: string }).base_url ?? (item as { url?: string }).url;
|
|
532
|
+
if (typeof raw === "string" && raw.trim()) {
|
|
533
|
+
const plant = (item as { plant?: string }).plant;
|
|
534
|
+
targets.push({
|
|
535
|
+
baseUrl: raw.trim().replace(/\/+$/, ""),
|
|
536
|
+
plant: typeof plant === "string" && plant.trim() ? plant.trim() : undefined,
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
} else if (parsed && typeof parsed === "object") {
|
|
542
|
+
for (const [plant, raw] of Object.entries(parsed as Record<string, unknown>)) {
|
|
543
|
+
if (typeof raw === "string" && raw.trim()) {
|
|
544
|
+
targets.push({ baseUrl: raw.trim().replace(/\/+$/, ""), plant });
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
} catch {
|
|
549
|
+
// ignore malformed pool JSON; fallback to CODEX_API_BASE(S)
|
|
550
|
+
}
|
|
551
|
+
if (targets.length > 0) return targets;
|
|
552
|
+
}
|
|
553
|
+
const baseList = envVar("CODEX_API_BASES");
|
|
554
|
+
if (baseList) {
|
|
555
|
+
for (const token of baseList.split(",")) {
|
|
556
|
+
const item = token.trim();
|
|
557
|
+
if (!item) continue;
|
|
558
|
+
if (item.includes("=")) {
|
|
559
|
+
const [plant, raw] = item.split("=", 2);
|
|
560
|
+
if (raw?.trim()) {
|
|
561
|
+
targets.push({ baseUrl: raw.trim().replace(/\/+$/, ""), plant: plant.trim() || undefined });
|
|
562
|
+
}
|
|
563
|
+
} else {
|
|
564
|
+
targets.push({ baseUrl: item.replace(/\/+$/, "") });
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
if (targets.length > 0) return targets;
|
|
568
|
+
}
|
|
569
|
+
const envBase = envVar("CODEX_API_BASE");
|
|
570
|
+
if (envBase && envBase.trim()) {
|
|
571
|
+
targets.push({ baseUrl: envBase.trim().replace(/\/+$/, "") });
|
|
572
|
+
}
|
|
573
|
+
return targets;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
private loadRequiredSectionVersions(): Record<string, string> {
|
|
577
|
+
const raw = envVar("CODEX_REQUIRED_SECTION_VERSIONS");
|
|
578
|
+
if (!raw) return {};
|
|
579
|
+
try {
|
|
580
|
+
const parsed = JSON.parse(raw);
|
|
581
|
+
if (!parsed || typeof parsed !== "object") return {};
|
|
582
|
+
const out: Record<string, string> = {};
|
|
583
|
+
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
|
|
584
|
+
if (typeof k === "string" && typeof v === "string" && k && v) out[k] = v;
|
|
585
|
+
}
|
|
586
|
+
return out;
|
|
587
|
+
} catch {
|
|
588
|
+
return {};
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
private orderedTargets(): { baseUrl: string; plant?: string }[] {
|
|
593
|
+
if (this.targets.length <= 1 || this.routeMode === "single") return [this.targets[0]!];
|
|
594
|
+
let preferred = this.targets.filter((t) => this.plant && t.plant === this.plant);
|
|
595
|
+
let others = this.targets.filter((t) => !this.plant || t.plant !== this.plant);
|
|
596
|
+
if (preferred.length === 0) {
|
|
597
|
+
preferred = [this.targets[0]!];
|
|
598
|
+
others = this.targets.slice(1);
|
|
599
|
+
}
|
|
600
|
+
const orderedPreferred = this.rotateByAffinity(preferred);
|
|
601
|
+
const orderedOthers = this.rotateByAffinity(others);
|
|
602
|
+
if (this.routeMode === "plant") return orderedPreferred;
|
|
603
|
+
const merged = [...orderedPreferred];
|
|
604
|
+
for (const target of orderedOthers) {
|
|
605
|
+
if (!merged.some((m) => m.baseUrl === target.baseUrl)) merged.push(target);
|
|
606
|
+
}
|
|
607
|
+
return merged;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
private rotateByAffinity(targets: { baseUrl: string; plant?: string }[]): {
|
|
611
|
+
baseUrl: string;
|
|
612
|
+
plant?: string;
|
|
613
|
+
}[] {
|
|
614
|
+
if (targets.length <= 1 || !this.affinityKey) return [...targets];
|
|
615
|
+
const key = this.affinityKey;
|
|
616
|
+
let hash = 2166136261;
|
|
617
|
+
for (let i = 0; i < key.length; i += 1) {
|
|
618
|
+
hash ^= key.charCodeAt(i);
|
|
619
|
+
hash = Math.imul(hash, 16777619) >>> 0;
|
|
620
|
+
}
|
|
621
|
+
const offset = hash % targets.length;
|
|
622
|
+
return [...targets.slice(offset), ...targets.slice(0, offset)];
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
private newRequestId(): string {
|
|
626
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
private async ensureContractCompatible(
|
|
630
|
+
target: { baseUrl: string; plant?: string },
|
|
631
|
+
requestId: string,
|
|
632
|
+
): Promise<void> {
|
|
633
|
+
if (Object.keys(this.requiredSectionVersions).length === 0) return;
|
|
634
|
+
const cached = this.contractCache.get(target.baseUrl);
|
|
635
|
+
let payload = cached;
|
|
636
|
+
if (!payload) {
|
|
637
|
+
const res = await this.fetchImpl(target.baseUrl + "/v1/contract", {
|
|
638
|
+
method: "GET",
|
|
639
|
+
headers: this.headers(target, requestId),
|
|
640
|
+
});
|
|
641
|
+
if (!res.ok) {
|
|
642
|
+
throw new CodexClientError(`failed to read /v1/contract from ${target.baseUrl}`, {
|
|
643
|
+
status: res.status,
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
payload = (await res.json()) as Record<string, unknown>;
|
|
647
|
+
this.contractCache.set(target.baseUrl, payload);
|
|
648
|
+
}
|
|
649
|
+
const sections = payload.section_schema_versions as Record<string, unknown> | undefined;
|
|
650
|
+
for (const [name, required] of Object.entries(this.requiredSectionVersions)) {
|
|
651
|
+
const got = sections?.[name];
|
|
652
|
+
if (typeof got !== "string" || got !== required) {
|
|
653
|
+
throw new CodexClientError(
|
|
654
|
+
`${target.baseUrl} incompatible section schema '${name}': required ${required}, got ${String(got)}`,
|
|
655
|
+
{ status: -1 },
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
449
661
|
async schema(name: string): Promise<unknown> {
|
|
450
662
|
const res = await this.get(`/v1/schema/${encodeURIComponent(name)}`);
|
|
451
663
|
return (await res.json()) as unknown;
|
|
@@ -62,6 +62,17 @@ curl -fsS https://codex-pdf-production.up.railway.app/v1/contract | jq .package_
|
|
|
62
62
|
# "1.3.1"
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
+
In multi-plant mode, also verify contract compatibility and failover:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
curl -fsS https://<plant-codex>/v1/contract | jq .section_schema_versions
|
|
69
|
+
curl -fsS https://<shared-codex>/v1/contract | jq .section_schema_versions
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Both maps must satisfy each consumer's
|
|
73
|
+
`CODEX_REQUIRED_SECTION_VERSIONS`. Then force one plant endpoint down
|
|
74
|
+
and re-run each smoke script to confirm hybrid failover works.
|
|
75
|
+
|
|
65
76
|
## Why a fixed order matters
|
|
66
77
|
|
|
67
78
|
- Marketing sites cache the codex base URL at build time when the
|
|
@@ -92,5 +103,8 @@ change one env var on the marketing service:
|
|
|
92
103
|
- Shared: `CODEX_API_BASE_URL=https://codex-pdf-production.up.railway.app`
|
|
93
104
|
- Sidecar: `CODEX_API_BASE_URL=https://${{codex-sidecar.RAILWAY_PRIVATE_DOMAIN}}`
|
|
94
105
|
(Railway service-reference; resolves at deploy time)
|
|
106
|
+
- Multi-plant hybrid:
|
|
107
|
+
`CODEX_API_BASES=plant-a=https://<codex-a>,shared=https://<codex-shared>`
|
|
108
|
+
+ `CODEX_ROUTE_MODE=hybrid` + `CODEX_PLANT=plant-a`
|
|
95
109
|
|
|
96
110
|
See `codex-pdf/docs/deploy.md` for the full switch-back recipe.
|