jevify 0.2.0__tar.gz → 0.3.0__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 (171) hide show
  1. jevify-0.3.0/.github/workflows/publish.yml +133 -0
  2. {jevify-0.2.0 → jevify-0.3.0}/PKG-INFO +12 -10
  3. {jevify-0.2.0 → jevify-0.3.0}/README.md +11 -9
  4. {jevify-0.2.0 → jevify-0.3.0}/SKILL.md +12 -5
  5. {jevify-0.2.0 → jevify-0.3.0}/docs/adr/0002-backend-port-and-adapter-kinds.md +22 -0
  6. jevify-0.3.0/docs/adr/0007-release-publishing.md +65 -0
  7. {jevify-0.2.0 → jevify-0.3.0}/docs/adr/README.md +1 -0
  8. {jevify-0.2.0 → jevify-0.3.0}/docs/playbook.md +5 -3
  9. {jevify-0.2.0 → jevify-0.3.0}/jevify/__init__.py +1 -1
  10. {jevify-0.2.0 → jevify-0.3.0}/jevify/adapters/endpoint/adapter.py +41 -12
  11. {jevify-0.2.0 → jevify-0.3.0}/jevify/adapters/endpoint/dialects.py +40 -14
  12. {jevify-0.2.0 → jevify-0.3.0}/jevify/adapters/endpoint/probe.py +78 -11
  13. {jevify-0.2.0 → jevify-0.3.0}/jevify/adapters/endpoint/readout.py +31 -8
  14. {jevify-0.2.0 → jevify-0.3.0}/jevify/ports/backend.py +3 -0
  15. {jevify-0.2.0 → jevify-0.3.0}/pyproject.toml +1 -1
  16. {jevify-0.2.0 → jevify-0.3.0}/tests/fakes/fake_openai_server.py +105 -26
  17. jevify-0.3.0/tests/fixtures/recipes/fake-tensorfold-messages.yaml +41 -0
  18. {jevify-0.2.0 → jevify-0.3.0}/tests/guards/test_request_fields.py +14 -0
  19. jevify-0.3.0/tests/test_prompt_logprobs_messages.py +149 -0
  20. {jevify-0.2.0 → jevify-0.3.0}/tests/test_prompt_logprobs_rung.py +0 -10
  21. {jevify-0.2.0 → jevify-0.3.0}/uv.lock +11 -11
  22. {jevify-0.2.0 → jevify-0.3.0}/.github/workflows/ci.yml +0 -0
  23. {jevify-0.2.0 → jevify-0.3.0}/.gitignore +0 -0
  24. {jevify-0.2.0 → jevify-0.3.0}/AGENTS.md +0 -0
  25. {jevify-0.2.0 → jevify-0.3.0}/LICENSE +0 -0
  26. {jevify-0.2.0 → jevify-0.3.0}/Makefile +0 -0
  27. {jevify-0.2.0 → jevify-0.3.0}/docs/00-invariants.md +0 -0
  28. {jevify-0.2.0 → jevify-0.3.0}/docs/adr/0001-founding-choices.md +0 -0
  29. {jevify-0.2.0 → jevify-0.3.0}/docs/adr/0003-jev-compatible-api.md +0 -0
  30. {jevify-0.2.0 → jevify-0.3.0}/docs/adr/0004-recipes.md +0 -0
  31. {jevify-0.2.0 → jevify-0.3.0}/docs/adr/0005-evaluation-evidence-and-calibration.md +0 -0
  32. {jevify-0.2.0 → jevify-0.3.0}/docs/adr/0006-toolchain-dependency-budget-and-portability.md +0 -0
  33. {jevify-0.2.0 → jevify-0.3.0}/docs/evidence/bart-large-mnli.local--policy-29.json +0 -0
  34. {jevify-0.2.0 → jevify-0.3.0}/docs/evidence/bart-large-mnli.local--policy-29.md +0 -0
  35. {jevify-0.2.0 → jevify-0.3.0}/docs/evidence/bart-large-mnli.local--policy-hard-52.json +0 -0
  36. {jevify-0.2.0 → jevify-0.3.0}/docs/evidence/bart-large-mnli.local--policy-hard-52.md +0 -0
  37. {jevify-0.2.0 → jevify-0.3.0}/docs/evidence/deepseek-v4-flash-vision-exp-keys.vllm--calibration.json +0 -0
  38. {jevify-0.2.0 → jevify-0.3.0}/docs/evidence/deepseek-v4-flash-vision-exp-keys.vllm--calibration.md +0 -0
  39. {jevify-0.2.0 → jevify-0.3.0}/docs/evidence/deepseek-v4-flash-vision-exp-keys.vllm--doom-frames-41.json +0 -0
  40. {jevify-0.2.0 → jevify-0.3.0}/docs/evidence/deepseek-v4-flash-vision-exp-keys.vllm--doom-frames-41.md +0 -0
  41. {jevify-0.2.0 → jevify-0.3.0}/docs/evidence/deepseek-v4-flash-vision-exp-keys.vllm--long-state-24.json +0 -0
  42. {jevify-0.2.0 → jevify-0.3.0}/docs/evidence/deepseek-v4-flash-vision-exp-keys.vllm--long-state-24.md +0 -0
  43. {jevify-0.2.0 → jevify-0.3.0}/docs/evidence/deepseek-v4-flash-vision-exp-keys.vllm--policy-29.json +0 -0
  44. {jevify-0.2.0 → jevify-0.3.0}/docs/evidence/deepseek-v4-flash-vision-exp-keys.vllm--policy-29.md +0 -0
  45. {jevify-0.2.0 → jevify-0.3.0}/docs/evidence/deepseek-v4-flash-vision-exp-keys.vllm--policy-hard-52.json +0 -0
  46. {jevify-0.2.0 → jevify-0.3.0}/docs/evidence/deepseek-v4-flash-vision-exp-keys.vllm--policy-hard-52.md +0 -0
  47. {jevify-0.2.0 → jevify-0.3.0}/docs/evidence/gemma-4-e4b-it.llamacpp--doom-frames-41.json +0 -0
  48. {jevify-0.2.0 → jevify-0.3.0}/docs/evidence/gemma-4-e4b-it.llamacpp--doom-frames-41.md +0 -0
  49. {jevify-0.2.0 → jevify-0.3.0}/docs/evidence/gemma-4-e4b-it.llamacpp--long-state-24.json +0 -0
  50. {jevify-0.2.0 → jevify-0.3.0}/docs/evidence/gemma-4-e4b-it.llamacpp--long-state-24.md +0 -0
  51. {jevify-0.2.0 → jevify-0.3.0}/docs/evidence/gemma-4-e4b-it.llamacpp--policy-29.json +0 -0
  52. {jevify-0.2.0 → jevify-0.3.0}/docs/evidence/gemma-4-e4b-it.llamacpp--policy-29.md +0 -0
  53. {jevify-0.2.0 → jevify-0.3.0}/docs/evidence/gemma-4-e4b-it.llamacpp--policy-hard-52.json +0 -0
  54. {jevify-0.2.0 → jevify-0.3.0}/docs/evidence/gemma-4-e4b-it.llamacpp--policy-hard-52.md +0 -0
  55. {jevify-0.2.0 → jevify-0.3.0}/docs/evidence/qwen3-embedding-0.6b.local--policy-29.json +0 -0
  56. {jevify-0.2.0 → jevify-0.3.0}/docs/evidence/qwen3-embedding-0.6b.local--policy-29.md +0 -0
  57. {jevify-0.2.0 → jevify-0.3.0}/docs/evidence/qwen3-embedding-0.6b.local--policy-hard-52.json +0 -0
  58. {jevify-0.2.0 → jevify-0.3.0}/docs/evidence/qwen3-embedding-0.6b.local--policy-hard-52.md +0 -0
  59. {jevify-0.2.0 → jevify-0.3.0}/docs/evidence/qwen3-vl-reranker-2b.llamacpp--doom-frames-41.json +0 -0
  60. {jevify-0.2.0 → jevify-0.3.0}/docs/evidence/qwen3-vl-reranker-2b.llamacpp--doom-frames-41.md +0 -0
  61. {jevify-0.2.0 → jevify-0.3.0}/docs/evidence/qwen3-vl-reranker-2b.llamacpp--policy-29.json +0 -0
  62. {jevify-0.2.0 → jevify-0.3.0}/docs/evidence/qwen3-vl-reranker-2b.llamacpp--policy-29.md +0 -0
  63. {jevify-0.2.0 → jevify-0.3.0}/docs/evidence/qwen3-vl-reranker-2b.llamacpp--policy-hard-52.json +0 -0
  64. {jevify-0.2.0 → jevify-0.3.0}/docs/evidence/qwen3-vl-reranker-2b.llamacpp--policy-hard-52.md +0 -0
  65. {jevify-0.2.0 → jevify-0.3.0}/docs/evidence/qwen3-vl-reranker-8b.llamacpp--policy-29.json +0 -0
  66. {jevify-0.2.0 → jevify-0.3.0}/docs/evidence/qwen3-vl-reranker-8b.llamacpp--policy-29.md +0 -0
  67. {jevify-0.2.0 → jevify-0.3.0}/docs/evidence/ternary-bonsai-2-27b.llamacpp--doom-frames-41.json +0 -0
  68. {jevify-0.2.0 → jevify-0.3.0}/docs/evidence/ternary-bonsai-2-27b.llamacpp--doom-frames-41.md +0 -0
  69. {jevify-0.2.0 → jevify-0.3.0}/docs/evidence/ternary-bonsai-2-27b.llamacpp--long-state-24.json +0 -0
  70. {jevify-0.2.0 → jevify-0.3.0}/docs/evidence/ternary-bonsai-2-27b.llamacpp--long-state-24.md +0 -0
  71. {jevify-0.2.0 → jevify-0.3.0}/docs/evidence/ternary-bonsai-2-27b.llamacpp--policy-29.json +0 -0
  72. {jevify-0.2.0 → jevify-0.3.0}/docs/evidence/ternary-bonsai-2-27b.llamacpp--policy-29.md +0 -0
  73. {jevify-0.2.0 → jevify-0.3.0}/docs/evidence/ternary-bonsai-2-27b.llamacpp--policy-hard-52.json +0 -0
  74. {jevify-0.2.0 → jevify-0.3.0}/docs/evidence/ternary-bonsai-2-27b.llamacpp--policy-hard-52.md +0 -0
  75. {jevify-0.2.0 → jevify-0.3.0}/jevify/__main__.py +0 -0
  76. {jevify-0.2.0 → jevify-0.3.0}/jevify/adapters/__init__.py +0 -0
  77. {jevify-0.2.0 → jevify-0.3.0}/jevify/adapters/embedding/__init__.py +0 -0
  78. {jevify-0.2.0 → jevify-0.3.0}/jevify/adapters/embedding/adapter.py +0 -0
  79. {jevify-0.2.0 → jevify-0.3.0}/jevify/adapters/embedding/local.py +0 -0
  80. {jevify-0.2.0 → jevify-0.3.0}/jevify/adapters/encoder/__init__.py +0 -0
  81. {jevify-0.2.0 → jevify-0.3.0}/jevify/adapters/encoder/adapter.py +0 -0
  82. {jevify-0.2.0 → jevify-0.3.0}/jevify/adapters/encoder/local.py +0 -0
  83. {jevify-0.2.0 → jevify-0.3.0}/jevify/adapters/endpoint/__init__.py +0 -0
  84. {jevify-0.2.0 → jevify-0.3.0}/jevify/adapters/endpoint/client.py +0 -0
  85. {jevify-0.2.0 → jevify-0.3.0}/jevify/adapters/rerank/__init__.py +0 -0
  86. {jevify-0.2.0 → jevify-0.3.0}/jevify/adapters/rerank/adapter.py +0 -0
  87. {jevify-0.2.0 → jevify-0.3.0}/jevify/api/__init__.py +0 -0
  88. {jevify-0.2.0 → jevify-0.3.0}/jevify/api/app.py +0 -0
  89. {jevify-0.2.0 → jevify-0.3.0}/jevify/api/schema.py +0 -0
  90. {jevify-0.2.0 → jevify-0.3.0}/jevify/api/translate.py +0 -0
  91. {jevify-0.2.0 → jevify-0.3.0}/jevify/cli.py +0 -0
  92. {jevify-0.2.0 → jevify-0.3.0}/jevify/compose.py +0 -0
  93. {jevify-0.2.0 → jevify-0.3.0}/jevify/domain/__init__.py +0 -0
  94. {jevify-0.2.0 → jevify-0.3.0}/jevify/domain/distribution.py +0 -0
  95. {jevify-0.2.0 → jevify-0.3.0}/jevify/domain/engine.py +0 -0
  96. {jevify-0.2.0 → jevify-0.3.0}/jevify/domain/questions.py +0 -0
  97. {jevify-0.2.0 → jevify-0.3.0}/jevify/evaluation/__init__.py +0 -0
  98. {jevify-0.2.0 → jevify-0.3.0}/jevify/evaluation/calibrate.py +0 -0
  99. {jevify-0.2.0 → jevify-0.3.0}/jevify/evaluation/controls.py +0 -0
  100. {jevify-0.2.0 → jevify-0.3.0}/jevify/evaluation/metrics.py +0 -0
  101. {jevify-0.2.0 → jevify-0.3.0}/jevify/evaluation/scorecard.py +0 -0
  102. {jevify-0.2.0 → jevify-0.3.0}/jevify/evaluation/suites.py +0 -0
  103. {jevify-0.2.0 → jevify-0.3.0}/jevify/ports/__init__.py +0 -0
  104. {jevify-0.2.0 → jevify-0.3.0}/jevify/recipes/__init__.py +0 -0
  105. {jevify-0.2.0 → jevify-0.3.0}/jevify/recipes/probe_case.yaml +0 -0
  106. {jevify-0.2.0 → jevify-0.3.0}/jevify/recipes/render.py +0 -0
  107. {jevify-0.2.0 → jevify-0.3.0}/jevify/recipes/schema.py +0 -0
  108. {jevify-0.2.0 → jevify-0.3.0}/jevify/recipes/store.py +0 -0
  109. {jevify-0.2.0 → jevify-0.3.0}/recipes/bart-large-mnli.local.yaml +0 -0
  110. {jevify-0.2.0 → jevify-0.3.0}/recipes/deepseek-v4-flash-vision-exp-keys.vllm.yaml +0 -0
  111. {jevify-0.2.0 → jevify-0.3.0}/recipes/gemma-4-e4b-it.llamacpp.yaml +0 -0
  112. {jevify-0.2.0 → jevify-0.3.0}/recipes/qwen3-embedding-0.6b.local.yaml +0 -0
  113. {jevify-0.2.0 → jevify-0.3.0}/recipes/qwen3-vl-reranker-2b.llamacpp.yaml +0 -0
  114. {jevify-0.2.0 → jevify-0.3.0}/recipes/qwen3-vl-reranker-8b.llamacpp.yaml +0 -0
  115. {jevify-0.2.0 → jevify-0.3.0}/recipes/qwen38-27b.vllm.yaml +0 -0
  116. {jevify-0.2.0 → jevify-0.3.0}/recipes/ternary-bonsai-2-27b.llamacpp.yaml +0 -0
  117. {jevify-0.2.0 → jevify-0.3.0}/suites/doom-frames-41/doom-frames-41.jsonl +0 -0
  118. {jevify-0.2.0 → jevify-0.3.0}/suites/doom-frames-41/doom-frames-41.manifest.json +0 -0
  119. {jevify-0.2.0 → jevify-0.3.0}/suites/long-state-24.jsonl +0 -0
  120. {jevify-0.2.0 → jevify-0.3.0}/suites/long-state-24.manifest.json +0 -0
  121. {jevify-0.2.0 → jevify-0.3.0}/suites/policy-29.jsonl +0 -0
  122. {jevify-0.2.0 → jevify-0.3.0}/suites/policy-29.manifest.json +0 -0
  123. {jevify-0.2.0 → jevify-0.3.0}/suites/policy-hard-52.jsonl +0 -0
  124. {jevify-0.2.0 → jevify-0.3.0}/suites/policy-hard-52.manifest.json +0 -0
  125. {jevify-0.2.0 → jevify-0.3.0}/tests/conftest.py +0 -0
  126. {jevify-0.2.0 → jevify-0.3.0}/tests/fakes/__init__.py +0 -0
  127. {jevify-0.2.0 → jevify-0.3.0}/tests/fakes/fake_backend.py +0 -0
  128. {jevify-0.2.0 → jevify-0.3.0}/tests/fixtures/recipes/fake-embedding.yaml +0 -0
  129. {jevify-0.2.0 → jevify-0.3.0}/tests/fixtures/recipes/fake-encoder.yaml +0 -0
  130. {jevify-0.2.0 → jevify-0.3.0}/tests/fixtures/recipes/fake-raw.yaml +0 -0
  131. {jevify-0.2.0 → jevify-0.3.0}/tests/fixtures/recipes/fake-rerank.yaml +0 -0
  132. {jevify-0.2.0 → jevify-0.3.0}/tests/fixtures/recipes/fake-tensorfold.yaml +0 -0
  133. {jevify-0.2.0 → jevify-0.3.0}/tests/fixtures/recipes/fake-vllm.yaml +0 -0
  134. {jevify-0.2.0 → jevify-0.3.0}/tests/fixtures/recipes/local-embedding.yaml +0 -0
  135. {jevify-0.2.0 → jevify-0.3.0}/tests/fixtures/recipes/local-encoder.yaml +0 -0
  136. {jevify-0.2.0 → jevify-0.3.0}/tests/fixtures/responses/llamacpp-completions-grammar.json +0 -0
  137. {jevify-0.2.0 → jevify-0.3.0}/tests/fixtures/responses/llamacpp-completions-top_k.json +0 -0
  138. {jevify-0.2.0 → jevify-0.3.0}/tests/fixtures/responses/llamacpp-native-completion.json +0 -0
  139. {jevify-0.2.0 → jevify-0.3.0}/tests/fixtures/responses/vllm-chat-top_k.json +0 -0
  140. {jevify-0.2.0 → jevify-0.3.0}/tests/guards/test_prompt_literals.py +0 -0
  141. {jevify-0.2.0 → jevify-0.3.0}/tests/guards/test_rung_registry.py +0 -0
  142. {jevify-0.2.0 → jevify-0.3.0}/tests/guards/test_sdk_schema_mirror.py +0 -0
  143. {jevify-0.2.0 → jevify-0.3.0}/tests/guards/test_structure.py +0 -0
  144. {jevify-0.2.0 → jevify-0.3.0}/tests/live/test_live_ask.py +0 -0
  145. {jevify-0.2.0 → jevify-0.3.0}/tests/live/test_live_sdk.py +0 -0
  146. {jevify-0.2.0 → jevify-0.3.0}/tests/test_api.py +0 -0
  147. {jevify-0.2.0 → jevify-0.3.0}/tests/test_calibrate.py +0 -0
  148. {jevify-0.2.0 → jevify-0.3.0}/tests/test_classify_and_states.py +0 -0
  149. {jevify-0.2.0 → jevify-0.3.0}/tests/test_cli.py +0 -0
  150. {jevify-0.2.0 → jevify-0.3.0}/tests/test_controls.py +0 -0
  151. {jevify-0.2.0 → jevify-0.3.0}/tests/test_distribution.py +0 -0
  152. {jevify-0.2.0 → jevify-0.3.0}/tests/test_embedding_adapter.py +0 -0
  153. {jevify-0.2.0 → jevify-0.3.0}/tests/test_encoder_adapter.py +0 -0
  154. {jevify-0.2.0 → jevify-0.3.0}/tests/test_endpoint_adapter.py +0 -0
  155. {jevify-0.2.0 → jevify-0.3.0}/tests/test_endpoint_client.py +0 -0
  156. {jevify-0.2.0 → jevify-0.3.0}/tests/test_engine.py +0 -0
  157. {jevify-0.2.0 → jevify-0.3.0}/tests/test_evaluation.py +0 -0
  158. {jevify-0.2.0 → jevify-0.3.0}/tests/test_llamacpp_dialect.py +0 -0
  159. {jevify-0.2.0 → jevify-0.3.0}/tests/test_local_embedding.py +0 -0
  160. {jevify-0.2.0 → jevify-0.3.0}/tests/test_multimodal.py +0 -0
  161. {jevify-0.2.0 → jevify-0.3.0}/tests/test_probe.py +0 -0
  162. {jevify-0.2.0 → jevify-0.3.0}/tests/test_readout_ladder.py +0 -0
  163. {jevify-0.2.0 → jevify-0.3.0}/tests/test_recipes.py +0 -0
  164. {jevify-0.2.0 → jevify-0.3.0}/tests/test_rerank_adapter.py +0 -0
  165. {jevify-0.2.0 → jevify-0.3.0}/tests/test_sdk_acceptance.py +0 -0
  166. {jevify-0.2.0 → jevify-0.3.0}/tests/test_translate.py +0 -0
  167. {jevify-0.2.0 → jevify-0.3.0}/tests/test_warm.py +0 -0
  168. {jevify-0.2.0 → jevify-0.3.0}/tools/author_long_state.py +0 -0
  169. {jevify-0.2.0 → jevify-0.3.0}/tools/author_policy_hard.py +0 -0
  170. {jevify-0.2.0 → jevify-0.3.0}/tools/play_doom.py +0 -0
  171. {jevify-0.2.0 → jevify-0.3.0}/tools/record_doom_suite.py +0 -0
@@ -0,0 +1,133 @@
1
+ # ADR-0007: a published GitHub release is built here from its tag and sent
2
+ # to PyPI by trusted publishing. No token is stored anywhere.
3
+ name: publish
4
+
5
+ on:
6
+ release:
7
+ types: [published]
8
+ # Dry run against an existing tag: builds, checks and compares, never publishes.
9
+ workflow_dispatch:
10
+ inputs:
11
+ tag:
12
+ description: "Release tag to build and check, e.g. v0.2.0"
13
+ required: true
14
+
15
+ permissions:
16
+ contents: read
17
+
18
+ concurrency:
19
+ group: publish-${{ inputs.tag || github.event.release.tag_name }}
20
+ cancel-in-progress: false
21
+
22
+ env:
23
+ TAG: ${{ inputs.tag || github.event.release.tag_name }}
24
+
25
+ jobs:
26
+ build:
27
+ name: build and check
28
+ runs-on: ubuntu-latest
29
+ outputs:
30
+ version: ${{ steps.version.outputs.version }}
31
+ attach: ${{ steps.compare.outputs.attach }}
32
+ steps:
33
+ - uses: actions/checkout@v4
34
+ with:
35
+ ref: refs/tags/${{ env.TAG }}
36
+ - uses: astral-sh/setup-uv@v6
37
+ with:
38
+ python-version: "3.12"
39
+ - name: Tag matches the package version
40
+ id: version
41
+ run: |
42
+ version="$(uv version --short)"
43
+ if [ "v${version}" != "${TAG}" ]; then
44
+ echo "::error::tag ${TAG} does not match version ${version} in pyproject.toml"
45
+ exit 1
46
+ fi
47
+ echo "version=${version}" >> "$GITHUB_OUTPUT"
48
+ - name: Build
49
+ run: uv build
50
+ - name: twine check
51
+ run: uvx twine check --strict dist/*
52
+ - name: Install the wheel as a tool and run it from another directory
53
+ env:
54
+ VERSION: ${{ steps.version.outputs.version }}
55
+ run: |
56
+ uv tool install dist/*.whl
57
+ cd "${RUNNER_TEMP}"
58
+ got="$(jevify --version)"
59
+ if [ "${got}" != "jevify ${VERSION}" ]; then
60
+ echo "::error::installed wheel reports '${got}', expected 'jevify ${VERSION}'"
61
+ exit 1
62
+ fi
63
+ - name: Compare with the files attached to the release
64
+ id: compare
65
+ env:
66
+ GH_TOKEN: ${{ github.token }}
67
+ GH_REPO: ${{ github.repository }}
68
+ run: |
69
+ attached_names="$(gh release view "${TAG}" --json assets \
70
+ -q '.assets[].name | select(endswith(".whl") or endswith(".tar.gz"))')"
71
+ if [ -z "${attached_names}" ]; then
72
+ echo "No wheel or sdist attached to ${TAG}; this build is attached after publishing."
73
+ echo "attach=true" >> "$GITHUB_OUTPUT"
74
+ exit 0
75
+ fi
76
+ dir="${RUNNER_TEMP}/attached"
77
+ mkdir -p "${dir}"
78
+ gh release download "${TAG}" -p '*.whl' -p '*.tar.gz' -D "${dir}"
79
+ built="$(cd dist && sha256sum -- *.whl *.tar.gz)"
80
+ attached="$(cd "${dir}" && sha256sum -- *.whl *.tar.gz)"
81
+ if [ "${built}" != "${attached}" ]; then
82
+ echo "::error::the files attached to ${TAG} differ from this build of the tag"
83
+ echo "built:"; echo "${built}"
84
+ echo "attached:"; echo "${attached}"
85
+ exit 1
86
+ fi
87
+ echo "Attached files are byte-identical to this build:"
88
+ echo "${built}"
89
+ echo "attach=false" >> "$GITHUB_OUTPUT"
90
+ - uses: actions/upload-artifact@v4
91
+ with:
92
+ name: dist
93
+ path: dist/
94
+ if-no-files-found: error
95
+
96
+ publish:
97
+ name: publish to PyPI
98
+ needs: build
99
+ if: github.event_name == 'release'
100
+ runs-on: ubuntu-latest
101
+ environment:
102
+ name: pypi
103
+ url: https://pypi.org/project/jevify/${{ needs.build.outputs.version }}/
104
+ permissions:
105
+ id-token: write
106
+ steps:
107
+ - uses: actions/download-artifact@v4
108
+ with:
109
+ name: dist
110
+ path: dist/
111
+ - uses: pypa/gh-action-pypi-publish@release/v1
112
+
113
+ attach:
114
+ name: attach build to the release
115
+ needs: [build, publish]
116
+ if: github.event_name == 'release' && needs.build.outputs.attach == 'true'
117
+ runs-on: ubuntu-latest
118
+ permissions:
119
+ contents: write
120
+ steps:
121
+ - uses: actions/download-artifact@v4
122
+ with:
123
+ name: dist
124
+ path: dist/
125
+ - name: Upload wheel, sdist and SHA256SUMS
126
+ env:
127
+ GH_TOKEN: ${{ github.token }}
128
+ GH_REPO: ${{ github.repository }}
129
+ run: |
130
+ cd dist
131
+ sha256sum -- *.whl *.tar.gz > SHA256SUMS
132
+ cat SHA256SUMS
133
+ gh release upload "${TAG}" *.whl *.tar.gz SHA256SUMS
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: jevify
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Make a pretrained model you already have answer typed questions in one pass, and measure how well it does.
5
5
  Project-URL: Homepage, https://github.com/fidecastro/jevify
6
6
  Author: Felipe Infante de Castro
@@ -89,9 +89,11 @@ that model and writes what it found into the file. vLLM and llama.cpp get
89
89
  their dialect extras; any other server is driven through the plain OpenAI
90
90
  surface. A server that refuses logprobs on the reply token but returns
91
91
  vLLM's `prompt_logprobs`, such as TensorFold's native server with
92
- GLM-5.3-Flash, is read through the `prompt_logprobs` rung, which needs a
93
- raw-mode recipe (the template written out with the model's own control
94
- tokens, served on `/v1/completions`).
92
+ GLM-5.3-Flash, is read through the `prompt_logprobs` rung on
93
+ `/v1/completions`: with a raw-mode recipe (the template written out with the
94
+ model's own control tokens), or with a chat-template recipe where the server
95
+ also takes `messages` on that route, as TensorFold's does. The chat form is
96
+ the one that carries images.
95
97
 
96
98
  `serve` binds to `127.0.0.1`. To reach it from other machines pass
97
99
  `--host 0.0.0.0`, and set `JEVIFY_API_KEY` first so that every request must
@@ -287,12 +289,12 @@ dies before the armour, so the corridor needs a policy with memory, not
287
289
  better perception); the rerank kind and the
288
290
  embedding route against a live server; a marker-slot encoder layout; the
289
291
  grammar rung on the PrismML llama.cpp fork, which accepts a grammar without
290
- constraining the reported probabilities; the `prompt_logprobs` rung against a
291
- live server (written for TensorFold's native server with GLM-5.3-Flash, run
292
- only against the fake server in `tests/` so far; a server that skips its
293
- prefix cache for such requests, as TensorFold's source shows it does, makes
294
- every question read the whole state again, which the probe measures and
295
- notes); Windows. Choice menus on the endpoint kind are bounded by the
292
+ constraining the reported probabilities; the `prompt_logprobs` rung on any
293
+ server but TensorFold's native one with GLM-5.3-Flash, where it has been
294
+ probed and asked by hand (raw and chat recipes, text and images) but no
295
+ evaluation suite has been scored on it yet (that server skips its prefix
296
+ cache for such requests, so every question reads the whole state again,
297
+ images included, which the probe measures and notes); Windows. Choice menus on the endpoint kind are bounded by the
296
298
  single-token identifier alphabet the probe verifies (26 on the DeepSeek
297
299
  recipe); Jev's 255-option ceiling is not reachable there.
298
300
 
@@ -59,9 +59,11 @@ that model and writes what it found into the file. vLLM and llama.cpp get
59
59
  their dialect extras; any other server is driven through the plain OpenAI
60
60
  surface. A server that refuses logprobs on the reply token but returns
61
61
  vLLM's `prompt_logprobs`, such as TensorFold's native server with
62
- GLM-5.3-Flash, is read through the `prompt_logprobs` rung, which needs a
63
- raw-mode recipe (the template written out with the model's own control
64
- tokens, served on `/v1/completions`).
62
+ GLM-5.3-Flash, is read through the `prompt_logprobs` rung on
63
+ `/v1/completions`: with a raw-mode recipe (the template written out with the
64
+ model's own control tokens), or with a chat-template recipe where the server
65
+ also takes `messages` on that route, as TensorFold's does. The chat form is
66
+ the one that carries images.
65
67
 
66
68
  `serve` binds to `127.0.0.1`. To reach it from other machines pass
67
69
  `--host 0.0.0.0`, and set `JEVIFY_API_KEY` first so that every request must
@@ -257,12 +259,12 @@ dies before the armour, so the corridor needs a policy with memory, not
257
259
  better perception); the rerank kind and the
258
260
  embedding route against a live server; a marker-slot encoder layout; the
259
261
  grammar rung on the PrismML llama.cpp fork, which accepts a grammar without
260
- constraining the reported probabilities; the `prompt_logprobs` rung against a
261
- live server (written for TensorFold's native server with GLM-5.3-Flash, run
262
- only against the fake server in `tests/` so far; a server that skips its
263
- prefix cache for such requests, as TensorFold's source shows it does, makes
264
- every question read the whole state again, which the probe measures and
265
- notes); Windows. Choice menus on the endpoint kind are bounded by the
262
+ constraining the reported probabilities; the `prompt_logprobs` rung on any
263
+ server but TensorFold's native one with GLM-5.3-Flash, where it has been
264
+ probed and asked by hand (raw and chat recipes, text and images) but no
265
+ evaluation suite has been scored on it yet (that server skips its prefix
266
+ cache for such requests, so every question reads the whole state again,
267
+ images included, which the probe measures and notes); Windows. Choice menus on the endpoint kind are bounded by the
266
268
  single-token identifier alphabet the probe verifies (26 on the DeepSeek
267
269
  recipe); Jev's 255-option ceiling is not reachable there.
268
270
 
@@ -81,7 +81,9 @@ headers, headers_env}`, `template` and `answers`.
81
81
  own control tokens; use it when the chat template would not put the model
82
82
  in its trained regime (the Qwen3-VL reranker recipe is the example), and
83
83
  for a server that refuses reply-token logprobs (TensorFold's native server
84
- with GLM-5.3-Flash): only a raw prompt can carry the `prompt_logprobs` rung.
84
+ with GLM-5.3-Flash) when the state is text. The `prompt_logprobs` rung also
85
+ reads a messages-mode recipe where the server takes `messages` on
86
+ `/v1/completions` (TensorFold's does); that is the form that carries images.
85
87
  - `template.questions.choice_strategy`: `identifiers` (one request, options
86
88
  labelled A, B, C) or `per_option` (one yes/no request per option; needed
87
89
  for a model that never emits letters, such as a reranker).
@@ -90,7 +92,8 @@ headers, headers_env}`, `template` and `answers`.
90
92
  - `readout.rung: auto` walks the rungs the probe proved, strongest first:
91
93
  `named_token_logprobs` (vLLM with `logprob_token_ids`), `grammar`
92
94
  (llama.cpp), `top_k`, `prompt_logprobs` (each answer spelling appended to
93
- a raw prompt and read back from vLLM-style `prompt_logprobs` rows, one
95
+ a raw prompt, or sent as the assistant's reply in messages mode, and read
96
+ back from vLLM-style `prompt_logprobs` rows, one
94
97
  request more for every spelling the rows did not show; exact, off-menu mass
95
98
  visible), `equal_bias`, `top_k_floor` (degraded, missing labels floored and
96
99
  listed). A rung that cannot see every label hands the
@@ -167,9 +170,13 @@ TypeSafe's published thresholds refer to. Everything jevify adds is under
167
170
  user turn right after the state. Use `template.mode: raw` so the warm prompt
168
171
  ends at the state boundary; the Bonsai recipe is the example.
169
172
  - **Probe notes "reply-token logprobs refused"**: the server gives no
170
- logprobs on a generated token (TensorFold with GLM-5.3-Flash). Write a raw
171
- recipe so the probe can prove `prompt_logprobs`; with a chat recipe the
172
- probe ends in "no readout is possible".
173
+ logprobs on a generated token (TensorFold with GLM-5.3-Flash). The probe
174
+ then tries `prompt_logprobs`: a raw recipe on any such server, a chat
175
+ recipe where the server takes `messages` on `/v1/completions`; elsewhere a
176
+ chat recipe ends in "no readout is possible".
177
+ - **"is not at the prompt row the probe measured"**: in messages mode the
178
+ server's template renders the answer turn differently than when the probe
179
+ ran (thinking switched on or off, a new template); re-run the probe.
173
180
  - **"did not come back as its own prompt token"**: the appended answer fuses
174
181
  with the end of the raw template; end the template where the answer starts
175
182
  a token of its own (after a newline, or before a leading-space spelling).
@@ -158,6 +158,28 @@ that request twice and records when the repeat reuses no cached prefix,
158
158
  since TensorFold skips its prefix cache for any request with
159
159
  `prompt_logprobs` and each question then reads the whole state again.
160
160
 
161
+ Amended 2026-11-06, for images on that server: a raw prompt carries no
162
+ image, so the rung above left such a server text-only. TensorFold's
163
+ `/v1/completions` also takes a chat `messages` list, image parts included,
164
+ renders it through the model's template and serves `prompt_logprobs` on it
165
+ (its GLM prefill encodes the images and records the prompt rows in one
166
+ pass). In messages mode the rung therefore sends the readout there, with the
167
+ answer spelling as the assistant's reply. The template always appends its
168
+ generation prompt, so the answer's row is not the last: the probe sends the
169
+ reply twice with two different answers, requires the rows to differ in
170
+ exactly one position that holds each answer, and records the rows after it
171
+ as `prompt_rows_after_answer`. A search for the answer's token from the end
172
+ was rejected, because it can find the same word in the question. Each
173
+ readout reads that row and refuses one that does not hold the appended
174
+ answer, naming a re-probe: a template that renders differently later (thinking
175
+ switched) fails loudly instead of being read at the wrong position. When the
176
+ strongest proven rung reads prompt rows, the warm request and the probe's
177
+ cache, fan-out and image measurements take the same route, so the image is
178
+ proven where questions will send it and a server that refuses reply-token
179
+ logprobs on the chat route never gets one. A server that takes no `messages`
180
+ on `/v1/completions` (vLLM) leaves the rung unproven for a chat recipe, as
181
+ before.
182
+
161
183
  ### D5 — Choice questions on decoders
162
184
 
163
185
  Options are labeled with single-token identifiers and the distribution is
@@ -0,0 +1,65 @@
1
+ # ADR-0007 — Releases reach PyPI from CI, by trusted publishing
2
+
3
+ - **Status:** accepted (2026-11-04).
4
+ - **Extends:** ADR-0006 D4 (build and distribution); records who builds the
5
+ files that go to PyPI and how they get there.
6
+
7
+ ## Context
8
+
9
+ 0.1.0 and 0.2.0 were built by hand with `uv build` from their tags, attached
10
+ to the GitHub release with a `SHA256SUMS`, and uploaded to PyPI with a token.
11
+ The upload step needed a PyPI token on the operator's machine, and the house
12
+ rule keeps secrets out of files, logs and chat. A rebuild of `v0.2.0` from a
13
+ fresh clone produced files byte-identical to the ones attached to the
14
+ release, so the build is reproducible and a second build can check the first.
15
+
16
+ ## Decision
17
+
18
+ ### D1 — Publishing a GitHub release publishes to PyPI
19
+
20
+ `.github/workflows/publish.yml` runs when a release is published. It checks
21
+ out the release tag, refuses a tag that is not `v` + the version in
22
+ `pyproject.toml`, builds with `uv build`, runs `twine check --strict`, and
23
+ installs the wheel as a tool and runs `jevify --version`.
24
+
25
+ ### D2 — Trusted publishing, no stored token
26
+
27
+ The upload uses PyPI trusted publishing (OIDC) from the `publish` job in the
28
+ GitHub environment `pypi`. No PyPI token exists in the repository, its
29
+ secrets or the operator's machine. The PyPI project's trusted publisher names
30
+ this repository, the workflow file `publish.yml` and the environment `pypi`;
31
+ renaming either breaks publishing until PyPI is updated.
32
+
33
+ ### D3 — The CI build must match what the release shows
34
+
35
+ If the release already carries a wheel and an sdist, the CI build must be
36
+ byte-identical to them or the workflow stops before uploading. If it carries
37
+ none, the CI build is uploaded to PyPI and then attached to the release with
38
+ a `SHA256SUMS`. Either way the files on PyPI and on the GitHub release are
39
+ the same bytes. Files are attached while the release is a draft, before it
40
+ is published, so the workflow never races a half-attached release.
41
+
42
+ ### D4 — Dry run
43
+
44
+ `workflow_dispatch` with a `tag` runs the same build and checks against an
45
+ existing release without publishing.
46
+
47
+ ## Consequences
48
+
49
+ - Cutting a release is: bump the version, merge, tag, then publish a GitHub
50
+ release from the tag. Publishing it is the PyPI upload; there is no
51
+ separate step.
52
+ - Each job holds only the permission it needs: `contents: read` to build,
53
+ `id-token: write` to publish, `contents: write` to attach.
54
+ - A non-reproducible build (a new hatchling changing metadata, say) shows up
55
+ as a mismatch in D3 when files were attached by hand. The remedy is to let
56
+ CI attach them.
57
+
58
+ ## Rejected alternatives
59
+
60
+ - **An API token in repository secrets.** It works, but it is a long-lived
61
+ secret that trusted publishing makes unnecessary.
62
+ - **Uploading the hand-attached files as they are.** PyPI would carry files
63
+ that no CI run built; D3 keeps hand-built files allowed but checked.
64
+ - **Publishing on tag push.** A tag is not a release note; the GitHub release
65
+ is the reviewed announcement, so it is the trigger.
@@ -18,3 +18,4 @@ later documents can cite "ADR-0002 D3".
18
18
  | [0004](0004-recipes.md) | Recipes: the hashed file that is the only way a model is asked | Accepted (2026-09-19) |
19
19
  | [0005](0005-evaluation-evidence-and-calibration.md) | Suites, scorecards, committed evidence, post-hoc calibration | Accepted (2026-09-19) |
20
20
  | [0006](0006-toolchain-dependency-budget-and-portability.md) | Toolchain, dependency budget and portability: five light core packages, extras, argparse, uv/pipx, Python 3.12–3.14, no lock-in | Accepted (2026-09-19); makes ADR-0001 D4 precise |
21
+ | [0007](0007-release-publishing.md) | Releases reach PyPI from CI by trusted publishing; the CI build must match the release files | Accepted (2026-11-04) |
@@ -229,8 +229,10 @@ two ways:
229
229
  do; any recipe works;
230
230
  - for the prompt (vLLM's `prompt_logprobs` on `/v1/completions`), as
231
231
  TensorFold's native server does for GLM-5.3-Flash, which refuses the
232
- first way; the recipe must use `template.mode: raw`, and the probe proves
233
- the `prompt_logprobs` rung.
232
+ first way; the probe proves the `prompt_logprobs` rung. A raw-mode recipe
233
+ works on any such server. A chat-template recipe works where the server
234
+ also takes a chat `messages` list on `/v1/completions` (TensorFold's does),
235
+ and is the one that can send images there.
234
236
 
235
237
  Many hosted APIs and proxies do neither; for those the probe stops with
236
238
  `no readout is possible`.
@@ -288,7 +290,7 @@ calibrated.
288
290
  | You see | Cause | Do this |
289
291
  |---|---|---|
290
292
  | `missing required header 'x-client-id'` | The server needs a header the recipe does not send. | Add `endpoint.headers: {x-client-id: <your product id>}`. |
291
- | `no readout is possible on this backend: it returned no logprobs` | The server or proxy returns no log-probabilities for the reply token, and the recipe could not read them through `prompt_logprobs`. | If the probe notes `reply-token logprobs refused`, write a raw-mode recipe so it can prove `prompt_logprobs` (step 6). Otherwise use a server that returns them (vLLM, llama.cpp). |
293
+ | `no readout is possible on this backend: it returned no logprobs` | The server or proxy returns no log-probabilities for the reply token, and the recipe could not read them through `prompt_logprobs`. | If the probe notes `reply-token logprobs refused`, write a raw-mode recipe so it can prove `prompt_logprobs` (step 6); a chat-template recipe proves it only where the server takes `messages` on `/v1/completions`. Otherwise use a server that returns them (vLLM, llama.cpp). |
292
294
  | `endpoint.headers names X-Api-Key, which looks like a credential` | A secret would be written into the recipe. | Move it to `endpoint.headers_env: {X-Api-Key: <VARIABLE>}` and export the variable. |
293
295
  | `endpoint.headers_env sends X-Api-Key from $GATEWAY_KEY, which is not set` | The variable the recipe names is not exported in this shell. | `export GATEWAY_KEY=...`, then run again. |
294
296
  | `degraded: true`, options listed in `missing` | The options were not among the model's most likely tokens. Often the model starts to reason instead of answering. | Turn reasoning off (step 6, item 5), then probe again. |
@@ -2,6 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.2.0"
5
+ __version__ = "0.3.0"
6
6
 
7
7
  __all__ = ["__version__"]
@@ -25,7 +25,7 @@ from jevify.ports.backend import (
25
25
  Rung,
26
26
  StateHandle,
27
27
  )
28
- from jevify.recipes.render import render_prefix, render_question
28
+ from jevify.recipes.render import RenderedPart, render_prefix, render_question
29
29
  from jevify.recipes.schema import Recipe
30
30
 
31
31
  Clock = Callable[[], float]
@@ -92,8 +92,12 @@ class EndpointBackend:
92
92
  slots = tuple(range(min(self.concurrency, self.slots))) if self.slots else ()
93
93
  started = self.clock()
94
94
  cached: int | None = None
95
+ # the route the questions will take, so a server that refuses one never sees it
96
+ prompt_rows = self._reads_prompt_rows()
95
97
  for slot in slots or (None,):
96
- path, body = base_request(self.recipe.model.name, prefix, "", self.dialect)
98
+ path, body = base_request(
99
+ self.recipe.model.name, prefix, "", self.dialect, prompt_rows=prompt_rows
100
+ )
97
101
  if slot is not None:
98
102
  body["id_slot"] = slot
99
103
  post = self.client.post_root if path == "completion" else self.client.post_v1
@@ -130,6 +134,35 @@ class EndpointBackend:
130
134
  proven = Capabilities.from_dict(self.recipe.probe).rungs if self.recipe.probe else set()
131
135
  return [r for r in RUNG_RANK if r in proven and r in RUNGS]
132
136
 
137
+ def _reads_prompt_rows(self) -> bool:
138
+ """Whether the strongest rung reads prompt rows; False before a probe on auto."""
139
+ if self.recipe.readout.rung == "auto" and not self.recipe.probe:
140
+ return False
141
+ return RUNGS[self._resolve_rung()].reads_prompt_rows
142
+
143
+ def _readout_request(
144
+ self, handle: StateHandle, part: RenderedPart, impl: Any, slot: int | None, tally: _Tally
145
+ ) -> tuple[str, dict[str, Any], Send]:
146
+ """The one way a question's readout request is built: the rung's route, and the
147
+ sender that reads the answer's row where the rung reads prompt rows."""
148
+ path, body = base_request(
149
+ self.recipe.model.name,
150
+ handle.prefix,
151
+ part.text,
152
+ self.dialect,
153
+ prompt_rows=impl.reads_prompt_rows,
154
+ )
155
+ answer_row = -1
156
+ if impl.reads_prompt_rows and handle.prefix.mode == "messages":
157
+ after = self.capabilities.prompt_rows_after_answer if self.capabilities else None
158
+ if after is None:
159
+ raise BackendError(
160
+ "the prompt_logprobs rung in messages mode reads the row the probe "
161
+ "measured (probe.prompt_rows_after_answer); run `jevify probe <recipe>`"
162
+ )
163
+ answer_row = -1 - after
164
+ return path, body, self._sender(path, slot, tally, answer_row)
165
+
133
166
  def _resolve_rung(self) -> Rung:
134
167
  pinned = self.recipe.readout.rung
135
168
  proven = (
@@ -189,11 +222,9 @@ class EndpointBackend:
189
222
  raw_parts = []
190
223
  for order in orders:
191
224
  part = render_question(self.recipe, question, order=order).parts[0]
192
- path, body = base_request(
193
- self.recipe.model.name, handle.prefix, part.text, self.dialect
194
- )
225
+ path, body, send = self._readout_request(handle, part, impl, slot, tally)
195
226
  parsed = await impl.collect(
196
- self._sender(path, slot, tally),
227
+ send,
197
228
  body,
198
229
  part,
199
230
  self.recipe.readout.top_k,
@@ -232,7 +263,7 @@ class EndpointBackend:
232
263
  raw={"parts": raw_parts},
233
264
  )
234
265
 
235
- def _sender(self, path: str, slot: int | None, tally: _Tally) -> Send:
266
+ def _sender(self, path: str, slot: int | None, tally: _Tally, answer_row: int) -> Send:
236
267
  """The one way a readout request goes out: pinned to its slot, timed, counted."""
237
268
  post = self.client.post_root if path == "completion" else self.client.post_v1
238
269
 
@@ -243,7 +274,7 @@ class EndpointBackend:
243
274
  response = await post(path, body)
244
275
  tally.latency_ms += (self.clock() - started) * 1000.0
245
276
  tally.calls += 1
246
- return parse_response(path, response, self.dialect)
277
+ return parse_response(path, response, self.dialect, answer_row=answer_row)
247
278
 
248
279
  return send
249
280
 
@@ -263,11 +294,9 @@ class EndpointBackend:
263
294
  cached_tokens: int | None = None
264
295
  raw_parts = []
265
296
  for part in rendered.parts:
266
- path, body = base_request(
267
- self.recipe.model.name, handle.prefix, part.text, self.dialect
268
- )
297
+ path, body, send = self._readout_request(handle, part, impl, slot, tally)
269
298
  parsed = await impl.collect(
270
- self._sender(path, slot, tally),
299
+ send,
271
300
  body,
272
301
  part,
273
302
  self.recipe.readout.top_k,
@@ -34,12 +34,16 @@ class Parsed:
34
34
 
35
35
 
36
36
  def base_request(
37
- model: str, prefix: RenderedPrefix, suffix: str, dialect: Dialect
37
+ model: str, prefix: RenderedPrefix, suffix: str, dialect: Dialect, *, prompt_rows: bool = False
38
38
  ) -> tuple[str, dict[str, Any]]:
39
39
  """The readout request every rung starts from: one token, no sampling, logprobs on.
40
40
 
41
41
  Returns the route key and the body: "chat/completions" and "completions" live under
42
42
  /v1; "completion" is llama.cpp's native root route, used for raw multimodal input.
43
+
44
+ `prompt_rows`: the readout reads prompt rows, not the reply (the prompt_logprobs rung).
45
+ A messages-mode body then goes to /v1/completions, where TensorFold's native server takes
46
+ a chat `messages` list and serves `prompt_logprobs`, and asks for no reply logprobs.
43
47
  """
44
48
  if prefix.mode == "raw":
45
49
  if prefix.images:
@@ -87,13 +91,17 @@ def base_request(
87
91
  "stream": False,
88
92
  "logprobs": True,
89
93
  }
94
+ if prompt_rows:
95
+ del body["logprobs"]
90
96
  if prefix.prefill:
97
+ # on prompt rows the answer is appended to this message (the rung's shape)
91
98
  messages.append({"role": "assistant", "content": prefix.prefill})
92
- body["continue_final_message"] = True
93
- body["add_generation_prompt"] = False
99
+ if not prompt_rows:
100
+ body["continue_final_message"] = True
101
+ body["add_generation_prompt"] = False
94
102
  if prefix.kwargs and dialect is not Dialect.GENERIC:
95
103
  body["chat_template_kwargs"] = dict(prefix.kwargs)
96
- return "chat/completions", body
104
+ return ("completions" if prompt_rows else "chat/completions"), body
97
105
 
98
106
 
99
107
  def _base64_payload(data_uri: str) -> str:
@@ -113,7 +121,11 @@ def grammar_for(texts: list[str]) -> str:
113
121
  return f"root ::= {escaped}"
114
122
 
115
123
 
116
- def parse_response(path: str, response: dict[str, Any], dialect: Dialect) -> Parsed:
124
+ def parse_response(
125
+ path: str, response: dict[str, Any], dialect: Dialect, *, answer_row: int = -1
126
+ ) -> Parsed:
127
+ """`answer_row`: which prompt_logprobs row is the answer's, counted from the end (-1: the
128
+ last, a raw prompt; earlier where the template adds rows after an assistant reply)."""
117
129
  entries: list[Entry] = []
118
130
  if path == "completion":
119
131
  # llama.cpp native route: completion_probabilities plus top-level counters.
@@ -131,16 +143,10 @@ def parse_response(path: str, response: dict[str, Any], dialect: Dialect) -> Par
131
143
  usage = response.get("usage") or {}
132
144
  details = usage.get("prompt_tokens_details") or {}
133
145
  rows = choice.get("prompt_logprobs")
134
- if isinstance(rows, list) and rows and isinstance(rows[-1], dict):
146
+ if isinstance(rows, list) and len(rows) >= -answer_row and isinstance(rows[answer_row], dict):
135
147
  # vLLM's prompt_logprobs (TensorFold writes the same): null, then one {id: {logprob,
136
- # rank, decoded_token}} row a prompt token, the prompt's own token listed first. The
137
- # last row is the position of the last prompt token.
138
- row = [
139
- Entry(
140
- text=str(item.get("decoded_token", "")), logprob=float(item["logprob"]), id=int(key)
141
- )
142
- for key, item in rows[-1].items()
143
- ]
148
+ # rank, decoded_token}} row a prompt token, the prompt's own token listed first.
149
+ row = _row_entries(rows[answer_row])
144
150
  return Parsed(
145
151
  entries=tuple(row),
146
152
  prompt_tokens=usage.get("prompt_tokens"),
@@ -170,6 +176,26 @@ def parse_response(path: str, response: dict[str, Any], dialect: Dialect) -> Par
170
176
  )
171
177
 
172
178
 
179
+ def _row_entries(row: dict[str, Any]) -> list[Entry]:
180
+ return [
181
+ Entry(text=str(item.get("decoded_token", "")), logprob=float(item["logprob"]), id=int(key))
182
+ for key, item in row.items()
183
+ ]
184
+
185
+
186
+ def prompt_tokens_held(response: dict[str, Any]) -> tuple[Entry | None, ...]:
187
+ """Each prompt_logprobs row's own token (its first entry), None for the first row: the
188
+ prompt as the server tokenized it. Empty when the response lists no prompt rows."""
189
+ rows = response["choices"][0].get("prompt_logprobs")
190
+ if not isinstance(rows, list):
191
+ return ()
192
+ held: list[Entry | None] = []
193
+ for row in rows:
194
+ entries = _row_entries(row) if isinstance(row, dict) and row else []
195
+ held.append(entries[0] if entries else None)
196
+ return tuple(held)
197
+
198
+
173
199
  def _entry_from(item: dict[str, Any], dialect: Dialect) -> Entry:
174
200
  text = str(item.get("token", ""))
175
201
  ident = item.get("id")