fast-completions 0.3.0 → 0.4.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e2cecdfed6a68aef560e112239c184f36f20d906b7122ae1311cbeb92b726b4d
4
- data.tar.gz: cb1ecd28ceecb63144bc7ad9b9328ffd9624033c26d793c691496d67ea909551
3
+ metadata.gz: 826572d669198677861fb62faea347d0e74bf99ca3ba39d2e899a159161e762b
4
+ data.tar.gz: 4e5d12637cc2b273596a38d7cdd39b50ee5f2018a3ba0c18e2c4001a88c32e22
5
5
  SHA512:
6
- metadata.gz: 12dcd5ced8e6c2ecee077178c138425ccae68061e6e0b42c6ec6540f952a3a93d3862ca319f761cfdebef89c04fc21024c09883f072a9949a0dc1a4ccace96d0
7
- data.tar.gz: 934df8a658af5d27c9da6aca37ab8e0d999f5ff633149227c646888af30c8b63e6629452b227aab5d34e74aa5fd128bd283bab405bba0993d3e0fed0f06cc881
6
+ metadata.gz: 7dde6dc44db9e1993071f6f494a743dad1e89990c67c6b2beeef61638e28cc57aff5af2026b66a42209d4eb27ee8c4848eec559593fbcdcbab1938bf5251d8fc
7
+ data.tar.gz: '09c28b1d9d980d29abd13a670feba40ec60fb79f3c65246251bc3b1ac042e3ae093728cf3fd2affbadfc09799c97f1f9f4fe59657c535b2376c2eeb0523a024a'
data/CHANGELOG.md CHANGED
@@ -3,6 +3,52 @@
3
3
  Notable changes to the fast-completions gem. The format is prose-first,
4
4
  one section per released version, newest first.
5
5
 
6
+ ## 0.4.1 (2026-10-16)
7
+
8
+ - Fix a mid-stream crash in the tool-call live-parse (regression against
9
+ pi, found live on zai/glm-5.3): when a delta boundary split an escape
10
+ sequence, the arguments scratch buffer ended mid-string with a lone
11
+ trailing backslash; `repair_json` doubled it (repaired != partial) and
12
+ the re-parse of the still-unterminated string raised `JSON::ParserError`
13
+ — uncaught, because Ruby rescue clauses do not cascade (an exception
14
+ raised in one rescue body is not handled by the next rescue clause of
15
+ the same method). The repaired re-parse now gets its own rescue (the
16
+ same lesson `Client#parse_repaired` already had), returning `{}` as the
17
+ last resort — pi's `parseStreamingJson` wraps the repair path in a
18
+ try/catch and always returns a valid object; the port dropped that
19
+ outer catch.
20
+ - Bench harness (perf-baseline): `gems/completions/bench/` ships four
21
+ deterministic, network-free, stdlib-only benchmark scripts over the hot
22
+ paths (text-heavy SSE+protocol stream; tool-args-heavy stream through the
23
+ O(n²) per-delta live re-parse; Codec normalize → to_wire round-trip;
24
+ strict-schema rewrite) and compare_refs.rb, which measures v0.2.0 →
25
+ v0.3.0 → v0.4.0 → 0.4.1 through temporary read-only worktrees and writes
26
+ bench/BASELINE.md — per-path × ref medians, consecutive-release deltas,
27
+ and per-pair verdicts against frozen per-op floors. No behavior changes;
28
+ the capture is single-machine, and future runs compare against the
29
+ floors, never the cells.
30
+
31
+ ## 0.4.0 (2026-10-14)
32
+
33
+ - Strict-sampling tool schemas (pi constrained-sampling, JSON-schema half):
34
+ tools declare `constrained_sampling` — `false` (explicit opt-out) or
35
+ `{"type" => "json_schema", "strict" => "prefer"|"require"}`; any other
36
+ shape raises `ArgumentError` at declaration (grammar-constrained sampling
37
+ stays out of scope — `type: "grammar"` raises). On a dialect declaring
38
+ `supports_strict_mode`, a declared tool ships the strict rewrite of its
39
+ parameters — every property required, non-required null-less properties
40
+ wrapped in an `anyOf` of the property and `{"type": "null"}`,
41
+ `additionalProperties: false` appended — with `"strict": true` right after
42
+ `parameters`; every other function tool on a strict dialect ships
43
+ `"strict": false` with parameters verbatim (the declared Hash is never
44
+ mutated — the rewrite deep-copies); tools on non-strict dialects carry no
45
+ strict key ever, parameters verbatim. `"prefer"` falls back to verbatim
46
+ silently when the schema is unsupportable; `"require"` raises the new
47
+ `ConstrainedSamplingError` with pi's message templates. The Dialect
48
+ vocabulary grows to 14 keys (`supports_strict_mode`, default `false` —
49
+ OpenAI compatibility alone does not imply strict tool support); the
50
+ shipped `:zai` declares `true` (pi's glm compat).
51
+
6
52
  ## 0.3.0 (2026-10-15)
7
53
 
8
54
  - Mid-stream salvage: a failure after a 2xx status — a mid-body connection
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fast
4
+ module Completions
5
+ # JSON-schema constrained sampling (I63) — the strict rewrite that lets
6
+ # a declared tool ship forced property emission on a strict dialect.
7
+ # Port of pi packages/ai/src/api/constrained-sampling.ts @ f1b2e77f5
8
+ # (the JSON-schema half; the grammar half is deliberately NOT ported —
9
+ # nothing half-anticipates grammar). Message bytes are pi's literals
10
+ # verbatim: they are the parity observable. Check order inside
11
+ # make_json_schema_node_strict is pi's exactly — which message throws
12
+ # first is observable behavior.
13
+ module ConstrainedSampling
14
+ # pi constrained-sampling.ts :17-34 (16 keys — the frozen order IS
15
+ # the first-present-key-wins order). NOTE: the iteration Grounds say
16
+ # "20 keys"; pi's array holds 16 and the Grounds enumerate the same
17
+ # 16 in the same order — the count numeral in the brief is wrong,
18
+ # pi is the oracle.
19
+ UNSUPPORTED_STRICT_SCHEMA_KEYS = [
20
+ "$ref",
21
+ "$defs",
22
+ "definitions",
23
+ "allOf",
24
+ "oneOf",
25
+ "patternProperties",
26
+ "dependentSchemas",
27
+ "dependencies",
28
+ "unevaluatedProperties",
29
+ "propertyNames",
30
+ "contains",
31
+ "prefixItems",
32
+ "not",
33
+ "if",
34
+ "then",
35
+ "else",
36
+ ].freeze
37
+
38
+ module_function
39
+
40
+ # pi :156-178. `true` (strict) or `nil` (loose). Only
41
+ # UnsupportedStrictJsonSchemaError is caught — any other error
42
+ # propagates (pi's rethrow).
43
+ def resolve_json_schema_strict_sampling(tool, supports_strict_mode)
44
+ config = tool.constrained_sampling
45
+ return nil if config.nil? || config == false || config["type"] != "json_schema"
46
+
47
+ if supports_strict_mode
48
+ begin
49
+ make_strict_json_schema(tool.parameters)
50
+ return true
51
+ rescue UnsupportedStrictJsonSchemaError => reason
52
+ return nil if config["strict"] != "require" # silent loose fallback
53
+
54
+ raise ConstrainedSamplingError,
55
+ "Tool \"#{tool.name}\" requires JSON-schema constrained sampling, but #{reason.message}."
56
+ end
57
+ end
58
+ if config["strict"] == "require"
59
+ raise ConstrainedSamplingError,
60
+ "Tool \"#{tool.name}\" requires JSON-schema constrained sampling, but strict tools are unsupported."
61
+ end
62
+ nil
63
+ end
64
+
65
+ # pi makeStrictJsonSchema :137-151: deep-copy FIRST (the caller's
66
+ # schema — nested included — is never mutated), then the root arms
67
+ # use the root-specific message (differs from the nested
68
+ # "boolean schemas…" arm), then the root type must be "object".
69
+ def make_strict_json_schema(schema)
70
+ cloned = deep_copy(schema)
71
+ raise UnsupportedStrictJsonSchemaError, "root schema must have type object" unless cloned.is_a?(Hash)
72
+
73
+ make_json_schema_node_strict(cloned)
74
+ raise UnsupportedStrictJsonSchemaError, "root schema must have type object" unless cloned["type"] == "object"
75
+
76
+ cloned
77
+ end
78
+
79
+ # pi makeJsonSchemaNodeStrict :64-134, IN PLACE on the (cloned) node.
80
+ def make_json_schema_node_strict(node)
81
+ # JS isJsonSchemaObject: object, not null, not array. Everything
82
+ # else — booleans, null, arrays, scalars — throws here (a nil
83
+ # property value hits this arm: JS undefined equivalence).
84
+ raise UnsupportedStrictJsonSchemaError, "boolean schemas are unsupported" unless node.is_a?(Hash)
85
+
86
+ # JS `schema[key] !== undefined`: present-with-any-value (JSON null
87
+ # included) throws; Ruby key? is the exact equivalence.
88
+ UNSUPPORTED_STRICT_SCHEMA_KEYS.each do |key|
89
+ raise UnsupportedStrictJsonSchemaError, "#{key} schemas are unsupported" if node.key?(key)
90
+ end
91
+
92
+ # pi's optional isUnsupportedKeyword provider hook is NOT ported:
93
+ # pi's own call sites pass none (Recorded residual R2).
94
+
95
+ if node.key?("anyOf") # pi :76-89
96
+ raise UnsupportedStrictJsonSchemaError, "anyOf must contain at least one schema" if
97
+ !node["anyOf"].is_a?(Array) || node["anyOf"].empty?
98
+
99
+ node["anyOf"].each do |variant|
100
+ raise UnsupportedStrictJsonSchemaError, "object and array unions are unsupported" if structured_schema?(variant)
101
+
102
+ make_json_schema_node_strict(variant)
103
+ end
104
+ end
105
+
106
+ if node.key?("items") # pi :91-96
107
+ raise UnsupportedStrictJsonSchemaError, "tuple schemas are unsupported" if node["items"].is_a?(Array)
108
+
109
+ make_json_schema_node_strict(node["items"])
110
+ end
111
+
112
+ # JS `schema.type === "object"` — the string, strictly.
113
+ object_schema = node["type"] == "object"
114
+ if node.key?("properties") && !object_schema
115
+ raise UnsupportedStrictJsonSchemaError, "properties require type object"
116
+ end
117
+ return unless object_schema # non-object-typed nodes return here
118
+
119
+ # JS `!== false`: only exactly boolean false passes.
120
+ if node.key?("additionalProperties") && node["additionalProperties"] != false
121
+ raise UnsupportedStrictJsonSchemaError, "schema-valued or true additionalProperties is unsupported"
122
+ end
123
+ if node.key?("properties") && !node["properties"].is_a?(Hash)
124
+ raise UnsupportedStrictJsonSchemaError, "object properties must be a schema map"
125
+ end
126
+ if node.key?("required") &&
127
+ (!node["required"].is_a?(Array) || node["required"].any? { |key| !key.is_a?(String) })
128
+ raise UnsupportedStrictJsonSchemaError, "object required must be a string array"
129
+ end
130
+
131
+ properties = node.key?("properties") ? node["properties"] : {}
132
+ property_names = properties.keys
133
+ required = node.key?("required") ? node["required"] : []
134
+ if required.any? { |key| !property_names.include?(key) }
135
+ raise UnsupportedStrictJsonSchemaError, "required contains an unknown property"
136
+ end
137
+
138
+ properties.each do |key, property|
139
+ make_json_schema_node_strict(property)
140
+ if !required.include?(key) && !schema_allows_null(property)
141
+ # Reassignment keeps the key's hash position (JS and Ruby alike).
142
+ properties[key] = {"anyOf" => [property, {"type" => "null"}]}
143
+ end
144
+ end
145
+ node["required"] = property_names # existing key keeps its position
146
+ node["additionalProperties"] = false # absent key appends
147
+ end
148
+
149
+ # pi schemaAllowsNull :54-60. The const arm needs the key-present
150
+ # check: JS `schema.const === null` is false when the key is absent
151
+ # (undefined ≠ null); Ruby `hash["const"].nil?` alone would read an
152
+ # ABSENT const as null-allowing and skip the wrap.
153
+ def schema_allows_null(schema)
154
+ return false unless schema.is_a?(Hash)
155
+
156
+ type = schema["type"]
157
+ return true if type == "null" || (type.is_a?(Array) && type.include?("null"))
158
+ return true if schema.key?("const") && schema["const"].nil?
159
+ return true if schema["enum"].is_a?(Array) && schema["enum"].include?(nil)
160
+
161
+ schema["anyOf"].is_a?(Array) && schema["anyOf"].any? { |variant| schema_allows_null(variant) }
162
+ end
163
+
164
+ # pi isStructuredSchema :42-51.
165
+ def structured_schema?(schema)
166
+ return false unless schema.is_a?(Hash)
167
+
168
+ types =
169
+ if schema["type"].is_a?(String) then [schema["type"]]
170
+ elsif schema["type"].is_a?(Array) then schema["type"]
171
+ else []
172
+ end
173
+ types.include?("object") || types.include?("array") ||
174
+ schema.key?("properties") || schema.key?("items")
175
+ end
176
+
177
+ # structuredClone's role: fresh containers all the way down, so the
178
+ # rewrite (property wraps, required/additionalProperties writes)
179
+ # never reaches the declared Hash. Leaves (strings, numbers, booleans,
180
+ # nil) are immutable in Ruby — no copy needed; frozen strings dup
181
+ # clean by simply being carried over.
182
+ def deep_copy(value)
183
+ case value
184
+ when Hash then value.each_with_object({}) { |(key, val), copy| copy[key] = deep_copy(val) }
185
+ when Array then value.map { |val| deep_copy(val) }
186
+ else value
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end
@@ -6,7 +6,7 @@ module Fast
6
6
  # the codec arms. Nothing here is provider detection, a catalog, or a
7
7
  # base-URL constant — the consumer declares, the codec obeys (§4).
8
8
  #
9
- # The vocabulary is CLOSED to the 13 keys declared below. Unknown keys
9
+ # The vocabulary is CLOSED to the 14 keys declared below. Unknown keys
10
10
  # and unknown enum values raise ArgumentError AT DECLARATION TIME —
11
11
  # fail loud, never silently mistranslate (the no-universal-translation
12
12
  # discipline).
@@ -27,6 +27,9 @@ module Fast
27
27
  supports_thinking_token_budget: [false, nil],
28
28
  send_session_affinity_headers: [false, nil],
29
29
  session_affinity_format: [:openai, %i[openai openai_nosession openrouter].freeze],
30
+ # I63 (pi detectCompat :1675): OpenAI compatibility alone does not
31
+ # imply strict JSON-schema tool support — data, not detection.
32
+ supports_strict_mode: [false, nil],
30
33
  }.freeze
31
34
 
32
35
  attr_reader :name
@@ -154,6 +157,7 @@ module Fast
154
157
  supports_developer_role false
155
158
  supports_reasoning_effort true
156
159
  supports_store false
160
+ supports_strict_mode true # pi zai.json glm compat: supportsStrictMode:true
157
161
  end
158
162
 
159
163
  Dialects.declare(:deepseek) do
@@ -61,6 +61,20 @@ module Fast
61
61
 
62
62
  class CancelledError < StandardError; end # NOT < Error (D8)
63
63
 
64
+ # I63 constrained sampling (pi api/constrained-sampling.ts). INTERNAL:
65
+ # a tool schema outside the strict subset pi's makeStrictJsonSchema
66
+ # accepts — the reason bytes are pi's literals verbatim. Never escapes
67
+ # the resolver: "prefer" falls back to loose on it, "require" converts
68
+ # it to the public ConstrainedSamplingError (pi rethrows only other
69
+ # error classes).
70
+ class UnsupportedStrictJsonSchemaError < Error; end
71
+
72
+ # PUBLIC: a strict:"require" tool whose schema cannot be strict-rewritten,
73
+ # or that runs on a dialect without supports_strict_mode — pi's two
74
+ # message templates verbatim (the inner reason carries no trailing
75
+ # period; the template adds it).
76
+ class ConstrainedSamplingError < Error; end
77
+
64
78
  # Internal: error classification helpers (Specification §8).
65
79
  module Errors
66
80
  # pi utils/overflow.ts :33-96 — the FULL pattern list, ported
@@ -66,18 +66,32 @@ module Fast
66
66
  end
67
67
 
68
68
  # §9.8 / pi buildParams :853-863: tools iff declared (declaration
69
- # order; parameters VERBATIM — the consumer's schema is the bytes);
70
- # zai tool_stream right after; the `tools: []` fallback when tool
71
- # history exists with no declared tools.
69
+ # order). Parameters stay VERBATIM — the consumer's schema is the
70
+ # bytes — EXCEPT the I63 strict arm: a tool declaring
71
+ # constrained_sampling on a strict dialect ships the strict REWRITE
72
+ # (pi convertTools :1505-1516, getJsonSchemaToolParameters) and the
73
+ # function object carries "strict" right after "parameters" (pi key
74
+ # order: name, description, parameters, strict) — declared-strict
75
+ # true, undeclared/loose false. VERBATIM remains the discipline for
76
+ # undeclared tools and non-strict dialects: no strict key ever there,
77
+ # and the emitted parameters are the declared object itself
78
+ # (object-identity emission). zai tool_stream right after; the
79
+ # `tools: []` fallback when tool history exists with no declared
80
+ # tools.
72
81
  def apply_tools(params, tools, messages, dialect)
73
82
  if tools.any?
83
+ strict_capable = dialect.supports_strict_mode
74
84
  params[:tools] = tools.map do |tool|
75
- {"type" => "function",
76
- "function" => {
77
- "name" => tool.name,
78
- "description" => tool.description,
79
- "parameters" => tool.parameters,
80
- }}
85
+ strict = ConstrainedSampling.resolve_json_schema_strict_sampling(tool, strict_capable)
86
+ function = {
87
+ "name" => tool.name,
88
+ "description" => tool.description,
89
+ "parameters" => strict == true ? ConstrainedSampling.make_strict_json_schema(tool.parameters) : tool.parameters,
90
+ }
91
+ # pi :1513 — the explicit key on EVERY function tool when the
92
+ # dialect supports strict mode ("Some reject unknown fields").
93
+ function["strict"] = strict || false if strict_capable
94
+ {"type" => "function", "function" => function}
81
95
  end
82
96
  params[:tool_stream] = true if dialect.zai_tool_stream
83
97
  elsif tool_history?(messages)
@@ -196,14 +210,24 @@ module Fast
196
210
  end
197
211
 
198
212
  # OQ-4: control-character repair (pi utils/json-parse.ts repairJson
199
- # :20-77), {} as the last resort (pi's partial-json fallback).
213
+ # :20-77), {} as the last resort (pi's partial-json fallback). The
214
+ # repaired re-parse gets its OWN method — a rescue body's exception
215
+ # is not caught by a later rescue clause of the same method (the
216
+ # 0.4.0 leak: a delta boundary splitting an escape sequence left the
217
+ # scratch buffer mid-string with a trailing backslash; repair "fixed"
218
+ # it, the re-parse raised, and the step died). pi's outer try/catch
219
+ # absorbs this; so do we.
200
220
  def parse_streaming_json(partial)
201
221
  return {} if partial.nil? || partial.strip.empty?
202
222
 
203
223
  JSON.parse(partial)
204
224
  rescue JSON::ParserError
205
225
  repaired = repair_json(partial)
206
- repaired == partial ? {} : JSON.parse(repaired)
226
+ repaired == partial ? {} : parse_repaired_json(repaired)
227
+ end
228
+
229
+ def parse_repaired_json(repaired)
230
+ JSON.parse(repaired)
207
231
  rescue JSON::ParserError
208
232
  {}
209
233
  end
@@ -25,12 +25,34 @@ module Fast
25
25
  end
26
26
  @parameters || {"type" => "object", "properties" => {}}
27
27
  end
28
+
29
+ # pi types.ts :736-754: constrainedSampling?: false |
30
+ # {type:"json_schema", strict:"prefer"|"require"}. Default nil
31
+ # (undeclared — no strict participation); false is the explicit
32
+ # opt-out, wire-equivalent to nil. ANY other shape raises here, at
33
+ # declaration time — fail loud, never silently mistranslate
34
+ # (unknown type — grammar included, the named exclusion — unknown
35
+ # strict value, missing keys, non-Hash values).
36
+ UNSET = Object.new.freeze # nil is NOT a passable value (raises below)
37
+
38
+ def constrained_sampling(config = UNSET)
39
+ return @constrained_sampling if config.equal?(UNSET)
40
+
41
+ unless config == false || (config.is_a?(Hash) &&
42
+ config["type"] == "json_schema" && %w[prefer require].include?(config["strict"]))
43
+ raise ArgumentError,
44
+ "constrained_sampling must be false or {\"type\" => \"json_schema\", " \
45
+ "\"strict\" => \"prefer\"|\"require\"}; got #{config.inspect}"
46
+ end
47
+ @constrained_sampling = config
48
+ end
28
49
  end
29
50
 
30
51
  # Instance readers forward to the class declaration.
31
52
  def description = self.class.description
32
53
  def tool_name = self.class.tool_name
33
54
  def parameters = self.class.parameters
55
+ def constrained_sampling = self.class.constrained_sampling
34
56
 
35
57
  # The wire name: the declared tool_name if set, else the class name (§11).
36
58
  def name
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Fast
4
4
  module Completions
5
- VERSION = "0.3.0"
5
+ VERSION = "0.4.1"
6
6
  end
7
7
  end
@@ -11,6 +11,7 @@ require_relative "completions/types"
11
11
  require_relative "completions/dialects"
12
12
  require_relative "completions/sse"
13
13
  require_relative "completions/codec"
14
+ require_relative "completions/constrained_sampling"
14
15
  require_relative "completions/protocol"
15
16
  require_relative "completions/client"
16
17
  require_relative "completions/tool"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fast-completions
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Jacobs
@@ -38,6 +38,7 @@ files:
38
38
  - lib/fast/completions/chat.rb
39
39
  - lib/fast/completions/client.rb
40
40
  - lib/fast/completions/codec.rb
41
+ - lib/fast/completions/constrained_sampling.rb
41
42
  - lib/fast/completions/dialects.rb
42
43
  - lib/fast/completions/errors.rb
43
44
  - lib/fast/completions/protocol.rb