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.
Files changed (148) hide show
  1. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/PKG-INFO +1 -1
  2. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/clients/ts/package.json +1 -1
  3. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/clients/ts/src/index.test.ts +46 -0
  4. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/clients/ts/src/index.ts +264 -52
  5. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/docs/operations/codex-change-ripple.md +14 -0
  6. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/pyproject.toml +1 -1
  7. codex_pdf-1.4.4/reports/dieline_calibration_report.json +10 -0
  8. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/schemas/v1/codex-document.schema.json +177 -0
  9. codex_pdf-1.4.4/scripts/calibrate_dieline_heuristics.py +92 -0
  10. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/client/http_client.py +272 -24
  11. 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
  12. codex_pdf-1.4.4/src/codex_pdf/extract/summary.py +277 -0
  13. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/models/v1.py +31 -1
  14. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/conftest.py +7 -0
  15. codex_pdf-1.4.4/tests/test_client_routing.py +102 -0
  16. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/test_summary_dieline.py +82 -0
  17. codex_pdf-1.4.4/tests/test_summary_spot_colors.py +58 -0
  18. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/uv.lock +1 -1
  19. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/.cursor/rules/service-ownership.mdc +0 -0
  20. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/.github/workflows/ci.yml +0 -0
  21. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/.gitignore +0 -0
  22. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/.windsurf/rules/service-ownership.md +0 -0
  23. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/APPROVALS.md +0 -0
  24. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/CLAUDE.md +0 -0
  25. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/Dockerfile +0 -0
  26. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/LICENSE +0 -0
  27. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/Procfile +0 -0
  28. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/README.md +0 -0
  29. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/clients/ts/README.md +0 -0
  30. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/clients/ts/package-lock.json +0 -0
  31. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/clients/ts/src/color.ts +0 -0
  32. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/clients/ts/tsconfig.json +0 -0
  33. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/docs/architecture.md +0 -0
  34. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/docs/backward-compatibility.md +0 -0
  35. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/docs/cleanup-stop-gates.md +0 -0
  36. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/docs/cli.md +0 -0
  37. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/docs/contract.md +0 -0
  38. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/docs/deploy.md +0 -0
  39. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/docs/discovery-audit.md +0 -0
  40. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/docs/migration-plan.md +0 -0
  41. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/docs/operations/marketing-deploy-template.md +0 -0
  42. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/docs/parity.md +0 -0
  43. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/docs/preflight-ingest.md +0 -0
  44. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/docs/release-1.0.0.md +0 -0
  45. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/docs/service-ownership-contract.md +0 -0
  46. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/railway.toml +0 -0
  47. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/reports/audit/mislocated-closure.json +0 -0
  48. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/reports/audit/produce_surface.json +0 -0
  49. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/reports/parity/codex_deep.json +0 -0
  50. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/reports/parity/codex_inventory.json +0 -0
  51. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/reports/parity/codex_summary.json +0 -0
  52. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/reports/parity/criterion4_parser_surface.json +0 -0
  53. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/reports/parity/pdfx4_deep.json +0 -0
  54. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/reports/parity/pdfx4_inventory.json +0 -0
  55. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/reports/parity/pdfx4_summary.json +0 -0
  56. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/reports/parity/render_baseline.json +0 -0
  57. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/reports/parity/viewer_essentials.json +0 -0
  58. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/schemas/CHANGELOG.md +0 -0
  59. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/schemas/v1/codex-annotation.schema.json +0 -0
  60. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/schemas/v1/codex-box.schema.json +0 -0
  61. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/schemas/v1/codex-color-space.schema.json +0 -0
  62. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/schemas/v1/codex-font.schema.json +0 -0
  63. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/schemas/v1/codex-form-xobject.schema.json +0 -0
  64. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/schemas/v1/codex-image.schema.json +0 -0
  65. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/schemas/v1/codex-issue.schema.json +0 -0
  66. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/schemas/v1/codex-ocg.schema.json +0 -0
  67. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/schemas/v1/codex-output-intent.schema.json +0 -0
  68. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/schemas/v1/codex-page-object.schema.json +0 -0
  69. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/schemas/v1/codex-page.schema.json +0 -0
  70. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/schemas/v1/codex-preflight-report.schema.json +0 -0
  71. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/schemas/v1/codex-source.schema.json +0 -0
  72. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/schemas/v1/codex-spot-colorant.schema.json +0 -0
  73. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/schemas/v1/codex-transparency-tree.schema.json +0 -0
  74. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/schemas/v1/codex-trap-evidence.schema.json +0 -0
  75. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/schemas/v1/codex-warning.schema.json +0 -0
  76. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/scripts/parity_viewer_essentials.py +0 -0
  77. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/scripts/produce_surface_audit.py +0 -0
  78. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/__init__.py +0 -0
  79. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/api/__init__.py +0 -0
  80. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/api/auth.py +0 -0
  81. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/api/cache.py +0 -0
  82. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/api/main.py +0 -0
  83. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/api/url_ingest.py +0 -0
  84. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/cli.py +0 -0
  85. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/client/__init__.py +0 -0
  86. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/color/__init__.py +0 -0
  87. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/color/color_math.py +0 -0
  88. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/color/curated.py +0 -0
  89. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/color/data/pantone_reference.json +0 -0
  90. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/color/normalize.py +0 -0
  91. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/color/pantone.py +0 -0
  92. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/color/resolver.py +0 -0
  93. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/eval/__init__.py +0 -0
  94. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/eval/ps_type4.py +0 -0
  95. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/extract/__init__.py +0 -0
  96. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/extract/annotations.py +0 -0
  97. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/extract/color.py +0 -0
  98. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/extract/common.py +0 -0
  99. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/extract/content_inventory.py +0 -0
  100. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/extract/document.py +0 -0
  101. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/extract/fonts.py +0 -0
  102. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/extract/forms.py +0 -0
  103. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/extract/images.py +0 -0
  104. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/extract/ocg.py +0 -0
  105. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/extract/signals.py +0 -0
  106. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/extract/structure.py +0 -0
  107. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/extract/transparency.py +0 -0
  108. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/extract/trapping.py +0 -0
  109. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/geom/__init__.py +0 -0
  110. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/geom/box.py +0 -0
  111. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/geom/matrix.py +0 -0
  112. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/geom/path.py +0 -0
  113. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/geom/tile.py +0 -0
  114. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/geom/units.py +0 -0
  115. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/models/__init__.py +0 -0
  116. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/parity.py +0 -0
  117. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/preflight_ingest/__init__.py +0 -0
  118. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/preflight_ingest/adapters.py +0 -0
  119. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/render/__init__.py +0 -0
  120. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/render/_common.py +0 -0
  121. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/render/content_stream.py +0 -0
  122. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/render/layer.py +0 -0
  123. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/render/page.py +0 -0
  124. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/render/separations.py +0 -0
  125. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/schema.py +0 -0
  126. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/src/codex_pdf/version.py +0 -0
  127. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/fixtures/conforming/minimal.pdf +0 -0
  128. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/fixtures/generate_fixtures.py +0 -0
  129. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/fixtures/violating/no_output_intent.pdf +0 -0
  130. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/fixtures/violating/no_trim_box.pdf +0 -0
  131. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/fixtures/violating/no_xmp.pdf +0 -0
  132. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/fixtures/violating/pdf_1_4.pdf +0 -0
  133. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/golden/1.0.0/reference.json +0 -0
  134. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/test_api.py +0 -0
  135. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/test_cache.py +0 -0
  136. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/test_cli_contract.py +0 -0
  137. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/test_color.py +0 -0
  138. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/test_extract_analysis_signals.py +0 -0
  139. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/test_extract_structural.py +0 -0
  140. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/test_geom.py +0 -0
  141. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/test_golden.py +0 -0
  142. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/test_golden_corpus.py +0 -0
  143. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/test_models.py +0 -0
  144. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/test_parity.py +0 -0
  145. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/test_preflight_ingest.py +0 -0
  146. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/test_produce_surface_audit.py +0 -0
  147. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/test_schema.py +0 -0
  148. {codex_pdf-1.4.2 → codex_pdf-1.4.4}/tests/test_schemas_all.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-pdf
3
- Version: 1.4.2
3
+ Version: 1.4.4
4
4
  Summary: Authoritative, versioned PDF facts contract for Think Neverland tools.
5
5
  Author-email: Think Neverland <dev@thinkneverland.com>
6
6
  License-Expression: AGPL-3.0-or-later
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@printwithsynergy/codex-client",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "TypeScript client for the codex-pdf HTTP API. Mirrors the Python codex_pdf.client surface.",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-or-later",
@@ -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 baseUrl = opts.baseUrl ?? envVar("CODEX_API_BASE");
285
- if (!baseUrl) {
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.baseUrl = baseUrl.replace(/\/+$/, "");
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(extra: Record<string, string> = {}): Record<string, string> {
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
- for (let attempt = 0; attempt <= this.maxRetries; attempt += 1) {
323
- const controller = new AbortController();
324
- const timer = setTimeout(() => controller.abort(), this.timeoutMs);
358
+ const requestId = this.newRequestId();
359
+ for (const target of this.orderedTargets()) {
325
360
  try {
326
- const headers: Record<string, string> = this.headers({ Accept: accept });
327
- if (contentType) headers["Content-Type"] = contentType;
328
- // For FormData, let fetch set Content-Type itself so the
329
- // multipart boundary is correct; we only override for JSON
330
- // and other explicit body types.
331
- const res = await this.fetchImpl(this.baseUrl + path, {
332
- method: "POST",
333
- headers,
334
- body,
335
- signal: controller.signal,
336
- });
337
- clearTimeout(timer);
338
- if (res.ok) return res;
339
- if (res.status === 408 || res.status === 429 || (res.status >= 500 && res.status < 600)) {
340
- lastErr = new CodexClientError(`codex ${path} -> ${res.status}`, {
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 controller = new AbortController();
366
- const timer = setTimeout(() => controller.abort(), this.timeoutMs);
367
- try {
368
- const res = await this.fetchImpl(this.baseUrl + path, {
369
- method: "GET",
370
- headers: this.headers(),
371
- signal: controller.signal,
372
- });
373
- clearTimeout(timer);
374
- if (!res.ok) {
375
- const text = await res.text().catch(() => "");
376
- throw new CodexClientError(`codex ${path} -> ${res.status}: ${text.slice(0, 1000)}`, {
377
- status: res.status,
378
- body: text,
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.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "codex-pdf"
7
- version = "1.4.2"
7
+ version = "1.4.4"
8
8
  description = "Authoritative, versioned PDF facts contract for Think Neverland tools."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -0,0 +1,10 @@
1
+ {
2
+ "dataset": "synthetic-structural-v1",
3
+ "negative_samples": 50,
4
+ "positive_samples": 25,
5
+ "negative_hits": 0,
6
+ "positive_hits": 25,
7
+ "false_positive_rate": 0.0,
8
+ "recall": 1.0,
9
+ "budget_false_positive_max": 0.03
10
+ }