fast-completions 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e2cecdfed6a68aef560e112239c184f36f20d906b7122ae1311cbeb92b726b4d
4
- data.tar.gz: cb1ecd28ceecb63144bc7ad9b9328ffd9624033c26d793c691496d67ea909551
3
+ metadata.gz: 5741f8a7684abbb66479904f8b49d14afebfd783cf3069e2e2c91717a0765238
4
+ data.tar.gz: 1a20474b48f9278d815b1561101a34cc229ac37c1cfcaeb83ffe9f1ba5bbcf9a
5
5
  SHA512:
6
- metadata.gz: 12dcd5ced8e6c2ecee077178c138425ccae68061e6e0b42c6ec6540f952a3a93d3862ca319f761cfdebef89c04fc21024c09883f072a9949a0dc1a4ccace96d0
7
- data.tar.gz: 934df8a658af5d27c9da6aca37ab8e0d999f5ff633149227c646888af30c8b63e6629452b227aab5d34e74aa5fd128bd283bab405bba0993d3e0fed0f06cc881
6
+ metadata.gz: bae0596f622cdee7289c271939aca77dc8e10d539aeed94b8c18388ad445957db4b19beb45a6359da7c971d3173d24e42ca917720499cfd081b398fd4e70f3ec
7
+ data.tar.gz: 30d7cc0ac12964163ca196e3e6a87e2e9e150de3db334ad342d55d923b55f0c522073108def6618c727a78593c5fb5d67f90d2e6d3267e794753575d9ab36bfe
data/CHANGELOG.md CHANGED
@@ -3,6 +3,27 @@
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.0 (2026-10-14)
7
+
8
+ - Strict-sampling tool schemas (pi constrained-sampling, JSON-schema half):
9
+ tools declare `constrained_sampling` — `false` (explicit opt-out) or
10
+ `{"type" => "json_schema", "strict" => "prefer"|"require"}`; any other
11
+ shape raises `ArgumentError` at declaration (grammar-constrained sampling
12
+ stays out of scope — `type: "grammar"` raises). On a dialect declaring
13
+ `supports_strict_mode`, a declared tool ships the strict rewrite of its
14
+ parameters — every property required, non-required null-less properties
15
+ wrapped in an `anyOf` of the property and `{"type": "null"}`,
16
+ `additionalProperties: false` appended — with `"strict": true` right after
17
+ `parameters`; every other function tool on a strict dialect ships
18
+ `"strict": false` with parameters verbatim (the declared Hash is never
19
+ mutated — the rewrite deep-copies); tools on non-strict dialects carry no
20
+ strict key ever, parameters verbatim. `"prefer"` falls back to verbatim
21
+ silently when the schema is unsupportable; `"require"` raises the new
22
+ `ConstrainedSamplingError` with pi's message templates. The Dialect
23
+ vocabulary grows to 14 keys (`supports_strict_mode`, default `false` —
24
+ OpenAI compatibility alone does not imply strict tool support); the
25
+ shipped `:zai` declares `true` (pi's glm compat).
26
+
6
27
  ## 0.3.0 (2026-10-15)
7
28
 
8
29
  - 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)
@@ -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.0"
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.0
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