k9o 1.2.0 → 1.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 +4 -4
- data/CHANGELOG.md +55 -1
- data/README.md +359 -68
- data/lib/k9o/ai.rb +15 -0
- data/lib/k9o/approvals.rb +159 -0
- data/lib/k9o/audit.rb +12 -14
- data/lib/k9o/client.rb +11 -7
- data/lib/k9o/compute.rb +32 -15
- data/lib/k9o/data.rb +67 -1
- data/lib/k9o/functions.rb +48 -9
- data/lib/k9o/gate.rb +9 -18
- data/lib/k9o/http_transport.rb +10 -5
- data/lib/k9o/kibo.rb +3 -3
- data/lib/k9o/me.rb +27 -87
- data/lib/k9o/org.rb +81 -68
- data/lib/k9o/orgs.rb +1 -0
- data/lib/k9o/policies.rb +25 -6
- data/lib/k9o/projects.rb +43 -8
- data/lib/k9o/query.rb +174 -0
- data/lib/k9o/realtime.rb +13 -35
- data/lib/k9o/region.rb +20 -18
- data/lib/k9o/schema.rb +35 -0
- data/lib/k9o/streams.rb +94 -0
- data/lib/k9o/studio.rb +21 -11
- data/lib/k9o/url_grammar.rb +46 -20
- data/lib/k9o/vault.rb +76 -80
- data/lib/k9o/version.rb +1 -1
- data/lib/k9o.rb +4 -0
- data/sig/k9o/ai.rbs +4 -0
- data/sig/k9o/approvals.rbs +49 -0
- data/sig/k9o/gate.rbs +7 -6
- data/sig/k9o/query.rbs +26 -0
- data/sig/k9o/region.rbs +14 -3
- metadata +8 -2
data/lib/k9o/ai.rb
CHANGED
|
@@ -7,6 +7,8 @@ module K9o
|
|
|
7
7
|
# Wire (project-plane):
|
|
8
8
|
# POST /@<slug>/ai/embeddings body { model?, input: String | [String] }
|
|
9
9
|
# -> { data: { embeddings: [[Float]], model:, dimensions: }, meta }
|
|
10
|
+
# POST /@<slug>/ai/generate body { prompt:, model?, maxTokens?, temperature?, usageType? }
|
|
11
|
+
# -> { data: { modelId:, content:, finishReason:, usage:, usageType: }, meta }
|
|
10
12
|
#
|
|
11
13
|
# Bounds are enforced by the gateway, not here. The SDK sends `input`
|
|
12
14
|
# exactly as given and never puts it in an error.
|
|
@@ -24,5 +26,18 @@ module K9o
|
|
|
24
26
|
HttpTransport.request(config: @config, method: 'POST',
|
|
25
27
|
path: UrlGrammar.project_path(slug, 'ai/embeddings'), body: body)
|
|
26
28
|
end
|
|
29
|
+
|
|
30
|
+
# POST /@<slug>/ai/generate — one synchronous text generation (verb sugar over compute.llms.invoke).
|
|
31
|
+
# Only the fields given are sent; without `model` the gateway uses the org's default LLM.
|
|
32
|
+
def generate(prompt, model: nil, max_tokens: nil, temperature: nil, usage_type: nil)
|
|
33
|
+
slug = Studio.require_project_slug(@config, 'ai')
|
|
34
|
+
body = { 'prompt' => prompt }
|
|
35
|
+
body['model'] = model unless model.nil?
|
|
36
|
+
body['maxTokens'] = max_tokens unless max_tokens.nil?
|
|
37
|
+
body['temperature'] = temperature unless temperature.nil?
|
|
38
|
+
body['usageType'] = usage_type unless usage_type.nil?
|
|
39
|
+
HttpTransport.request(config: @config, method: 'POST',
|
|
40
|
+
path: UrlGrammar.project_path(slug, 'ai/generate'), body: body)
|
|
41
|
+
end
|
|
27
42
|
end
|
|
28
43
|
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module K9o
|
|
4
|
+
# Shared argument checks for the approvals, approval policy and schema namespaces. A bad value raises
|
|
5
|
+
# K9o::KError K9O_INVALID_FIELD_VALUE before any request is sent.
|
|
6
|
+
module ApprovalChecks
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def invalid(message, context = {})
|
|
10
|
+
KError.new(code: 'K9O_INVALID_FIELD_VALUE', message: message, context: context)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def check_limit(limit)
|
|
14
|
+
return if limit.nil?
|
|
15
|
+
return if limit.is_a?(Integer) && limit >= 1 && limit <= 200
|
|
16
|
+
|
|
17
|
+
raise invalid('limit must be a whole number from 1 to 200', limit: limit)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def id_segment(value, what)
|
|
21
|
+
raise invalid("#{what} id must be a non-empty string") unless value.is_a?(String) && !value.empty?
|
|
22
|
+
|
|
23
|
+
::URI.encode_www_form_component(value).gsub('+', '%20')
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def reason!(reason)
|
|
27
|
+
raise invalid('reason is required and must be a non-empty string') unless reason.is_a?(String) && !reason.strip.empty?
|
|
28
|
+
|
|
29
|
+
reason
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def predicate!(value, field)
|
|
33
|
+
raise invalid("#{field} must be a predicate object") unless value.is_a?(Hash)
|
|
34
|
+
|
|
35
|
+
value
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def enum!(value, allowed, field)
|
|
39
|
+
text = value.is_a?(Symbol) ? value.to_s : value
|
|
40
|
+
return text if allowed.include?(text)
|
|
41
|
+
|
|
42
|
+
raise invalid("#{field} must be #{allowed.map { |a| "\"#{a}\"" }.join(', ')}", field.to_sym => value)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Pending-change queue: k9o.approvals.*
|
|
47
|
+
#
|
|
48
|
+
# list(status:, limit:) GET /@<slug>/pending-approvals
|
|
49
|
+
# get(id) GET /@<slug>/pending-approvals/<id>
|
|
50
|
+
# approve(id, reason:) POST /@<slug>/pending-approvals/<id>/approve
|
|
51
|
+
# reject(id, reason:) POST /@<slug>/pending-approvals/<id>/reject
|
|
52
|
+
# bulk(action:, criteria:, reason:) POST /@<slug>/pending-approvals/bulk
|
|
53
|
+
#
|
|
54
|
+
# +list+ is not paginated: it returns the newest +limit+ changes (1 to 200, default 50). +approve+ can answer 200
|
|
55
|
+
# with +status+ still +pending_approval+: the approval is recorded and more approvers are needed. +bulk+ skips rows
|
|
56
|
+
# that fail without a report, so compare +affectedIds+ with what you intended.
|
|
57
|
+
class Approvals
|
|
58
|
+
def initialize(config)
|
|
59
|
+
@config = config
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Pending changes, newest first. Only +limit+ caps the result.
|
|
63
|
+
def list(status: nil, limit: nil)
|
|
64
|
+
ApprovalChecks.check_limit(limit)
|
|
65
|
+
query = {}
|
|
66
|
+
query['status'] = status.to_s unless status.nil?
|
|
67
|
+
query['limit'] = limit unless limit.nil?
|
|
68
|
+
call('GET', path, query: query)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# One pending change. Members who are not queue admins can read only their own.
|
|
72
|
+
def get(id)
|
|
73
|
+
call('GET', path("/#{ApprovalChecks.id_segment(id, 'approval')}"))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Approve a pending change. Read +status+ on the answer: +pending_approval+ means more approvers are needed.
|
|
77
|
+
def approve(id, reason: nil)
|
|
78
|
+
seg = ApprovalChecks.id_segment(id, 'approval')
|
|
79
|
+
call('POST', path("/#{seg}/approve"), body: reason.nil? ? {} : { 'reason' => reason })
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Reject a pending change. +reason+ is required and must not be empty.
|
|
83
|
+
def reject(id, reason:)
|
|
84
|
+
seg = ApprovalChecks.id_segment(id, 'approval')
|
|
85
|
+
call('POST', path("/#{seg}/reject"), body: { 'reason' => ApprovalChecks.reason!(reason) })
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Approve or reject every pending change matching +criteria+ (a predicate Hash). +reason+ is required to reject.
|
|
89
|
+
# Rows that fail are skipped without a report: compare +affectedIds+ with what you intended.
|
|
90
|
+
def bulk(action:, criteria:, reason: nil)
|
|
91
|
+
act = ApprovalChecks.enum!(action, %w[approve reject], 'action')
|
|
92
|
+
body = { 'action' => act, 'criteria' => ApprovalChecks.predicate!(criteria, 'criteria') }
|
|
93
|
+
if act == 'reject'
|
|
94
|
+
body['reason'] = ApprovalChecks.reason!(reason)
|
|
95
|
+
elsif !reason.nil?
|
|
96
|
+
body['reason'] = reason
|
|
97
|
+
end
|
|
98
|
+
call('POST', path('/bulk'), body: body)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def call(method, route, body: nil, query: nil)
|
|
104
|
+
HttpTransport.request(config: @config, method: method, path: route, body: body, query: query)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def path(tail = '')
|
|
108
|
+
UrlGrammar.project_path(Studio.require_project_slug(@config, 'approvals'), "pending-approvals#{tail}")
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Approval policies: k9o.approval_policies.*
|
|
113
|
+
#
|
|
114
|
+
# list GET /@<slug>/approval-policies
|
|
115
|
+
# create(collection:, operation:, action:, predicate:) POST /@<slug>/approval-policies
|
|
116
|
+
# delete(id) DELETE /@<slug>/approval-policies/<id>
|
|
117
|
+
class ApprovalPolicies
|
|
118
|
+
COLLECTION_RE = /\A[a-zA-Z_][a-zA-Z0-9_]{0,62}\z/.freeze
|
|
119
|
+
|
|
120
|
+
def initialize(config)
|
|
121
|
+
@config = config
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# All policies, oldest first. The first matching policy for a write decides.
|
|
125
|
+
def list
|
|
126
|
+
call('GET', path)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Create a policy. Identical policies are not de-duplicated.
|
|
130
|
+
def create(collection:, operation:, action:, predicate:)
|
|
131
|
+
unless collection.is_a?(String) && COLLECTION_RE.match?(collection)
|
|
132
|
+
raise ApprovalChecks.invalid('collection must match ^[a-zA-Z_][a-zA-Z0-9_]{0,62}$', collection: collection)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
body = {
|
|
136
|
+
'collection' => collection,
|
|
137
|
+
'operation' => ApprovalChecks.enum!(operation, %w[insert update delete], 'operation'),
|
|
138
|
+
'action' => ApprovalChecks.enum!(action, %w[auto_approve queue], 'action'),
|
|
139
|
+
'predicate' => ApprovalChecks.predicate!(predicate, 'predicate')
|
|
140
|
+
}
|
|
141
|
+
call('POST', path, body: body)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Delete a policy; the answer is { 'id' => ... }.
|
|
145
|
+
def delete(id)
|
|
146
|
+
call('DELETE', path("/#{ApprovalChecks.id_segment(id, 'policy')}"))
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
def call(method, route, body: nil)
|
|
152
|
+
HttpTransport.request(config: @config, method: method, path: route, body: body)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def path(tail = '')
|
|
156
|
+
UrlGrammar.project_path(Studio.require_project_slug(@config, 'approval_policies'), "approval-policies#{tail}")
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
data/lib/k9o/audit.rb
CHANGED
|
@@ -2,27 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
module K9o
|
|
4
4
|
# Project-plane audit log namespace — k9o.audit.*.
|
|
5
|
-
# Distinct from k9o.org.audit_log
|
|
5
|
+
# Distinct from k9o.org.audit_log.
|
|
6
6
|
class Audit
|
|
7
7
|
def initialize(config)
|
|
8
8
|
@config = config
|
|
9
9
|
end
|
|
10
10
|
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
# GET /@<slug>/audit-log — reverse-chronological; filters AND-compose. Requires the AUDIT_READER role.
|
|
12
|
+
# event_type: audit-event name prefix (e.g. 'data.write.access') actor_id: filter by actor
|
|
13
|
+
# after / before: ISO 8601 bounds (events after / before this instant)
|
|
14
|
+
# limit: page size (bounded server-side) cursor: from the prior response's meta.cursor.next
|
|
15
|
+
def query(event_type: nil, actor_id: nil, after: nil, before: nil, limit: nil, cursor: nil)
|
|
16
16
|
slug = Studio.require_project_slug(@config, 'audit')
|
|
17
17
|
q = {}
|
|
18
|
-
q['
|
|
19
|
-
q['
|
|
20
|
-
q['
|
|
21
|
-
q['
|
|
22
|
-
q['
|
|
23
|
-
q['
|
|
24
|
-
q['limit'] = limit if limit
|
|
25
|
-
q['cursor'] = cursor if cursor
|
|
18
|
+
q['eventType'] = event_type unless event_type.nil?
|
|
19
|
+
q['actorId'] = actor_id unless actor_id.nil?
|
|
20
|
+
q['after'] = after unless after.nil?
|
|
21
|
+
q['before'] = before unless before.nil?
|
|
22
|
+
q['limit'] = limit unless limit.nil?
|
|
23
|
+
q['cursor'] = cursor unless cursor.nil?
|
|
26
24
|
HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.project_path(slug, 'audit-log'), query: q)
|
|
27
25
|
end
|
|
28
26
|
end
|
data/lib/k9o/client.rb
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
module K9o
|
|
4
4
|
# The customer-facing client returned by K9o.create_client.
|
|
5
5
|
class K9oClient
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
include QueryMethods
|
|
7
|
+
|
|
8
|
+
attr_reader :config, :projects, :gate, :studio, :compute, :realtime, :streams, :org,
|
|
9
|
+
:approvals, :approval_policies, :schema, :orgs, :migrations, :functions, :policies, :audit, :me, :kibo, :ai, :storage
|
|
8
10
|
|
|
9
11
|
def initialize(config)
|
|
10
12
|
@config = config
|
|
@@ -13,6 +15,10 @@ module K9o
|
|
|
13
15
|
@studio = Studio.new(config)
|
|
14
16
|
@compute = Compute.new(config)
|
|
15
17
|
@realtime = Realtime.new(config)
|
|
18
|
+
@streams = Streams.new(config)
|
|
19
|
+
@approvals = Approvals.new(config)
|
|
20
|
+
@approval_policies = ApprovalPolicies.new(config)
|
|
21
|
+
@schema = Schema.new(config)
|
|
16
22
|
@org = Org.new(config)
|
|
17
23
|
@orgs = Orgs.new(config)
|
|
18
24
|
@migrations = Migrations.new(config)
|
|
@@ -36,17 +42,15 @@ module K9o
|
|
|
36
42
|
|
|
37
43
|
# Factory — construct a K9oClient bound to the supplied options.
|
|
38
44
|
#
|
|
39
|
-
# Per spec sample:
|
|
40
45
|
# k9o = K9o.create_client(
|
|
41
46
|
# api_key: ENV["K9O_API_KEY"],
|
|
42
47
|
# org_slug: "acme",
|
|
43
48
|
# project_slug: "member-portal",
|
|
44
49
|
# )
|
|
45
50
|
#
|
|
46
|
-
# base_url is OPTIONAL. When omitted it
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
# https://<org_slug>.api.k9o.io is composed. Any other host passes through.
|
|
51
|
+
# base_url is OPTIONAL. When omitted it defaults to the public-cloud platform host (a convenience only, never used
|
|
52
|
+
# to parse a host). When the host is a platform host (+api.<apex>+ or +api-staging.<apex>+, any apex) and org_slug
|
|
53
|
+
# is set, the org host +<org_slug>.<host>+ is composed. Any other host passes through unchanged.
|
|
50
54
|
def self.create_client(base_url: nil, api_key: nil, org_slug: nil, project_slug: nil, default_headers: {}, http_client: nil)
|
|
51
55
|
normalised = UrlGrammar.resolve_base_url(base_url, org_slug)
|
|
52
56
|
config = HttpClientConfig.new(
|
data/lib/k9o/compute.rb
CHANGED
|
@@ -4,30 +4,37 @@ module K9o
|
|
|
4
4
|
# Compute namespace (Compute LLMs registry).
|
|
5
5
|
# k9o.compute.llms.*
|
|
6
6
|
class Compute
|
|
7
|
-
#
|
|
7
|
+
# The curated model ids (closed enum).
|
|
8
8
|
LLM_MODEL_IDS = %w[
|
|
9
9
|
mistral-7b mistral-8x7b llama-3-8b llama-3-70b
|
|
10
10
|
claude-3-5-sonnet gpt-4o gpt-4o-mini
|
|
11
11
|
text-embedding-3-small text-embedding-3-large
|
|
12
12
|
].freeze
|
|
13
13
|
|
|
14
|
+
# Inference parameters the gateway reads (top-level body fields next to +prompt+). Snake_case spellings map to
|
|
15
|
+
# the gateway's.
|
|
16
|
+
PARAM_FIELDS = {
|
|
17
|
+
'max_tokens' => 'maxTokens', 'maxTokens' => 'maxTokens',
|
|
18
|
+
'temperature' => 'temperature',
|
|
19
|
+
'usage_type' => 'usageType', 'usageType' => 'usageType'
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
14
22
|
attr_reader :llms
|
|
15
23
|
|
|
16
24
|
def initialize(config)
|
|
17
25
|
@llms = Llms.new(config)
|
|
18
26
|
end
|
|
19
27
|
|
|
20
|
-
# k9o.compute.llms
|
|
28
|
+
# k9o.compute.llms.*
|
|
21
29
|
class Llms
|
|
22
30
|
def initialize(config)
|
|
23
31
|
@config = config
|
|
24
32
|
end
|
|
25
33
|
|
|
26
|
-
|
|
34
|
+
# GET /@<slug>/compute/llms
|
|
35
|
+
def list
|
|
27
36
|
slug = Studio.require_project_slug(@config, 'compute.llms')
|
|
28
|
-
|
|
29
|
-
HttpTransport.request(config: @config, method: 'GET',
|
|
30
|
-
path: UrlGrammar.project_path(slug, 'compute/llms'), query: q)
|
|
37
|
+
HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.project_path(slug, 'compute/llms'))
|
|
31
38
|
end
|
|
32
39
|
|
|
33
40
|
def read(model_id)
|
|
@@ -36,29 +43,39 @@ module K9o
|
|
|
36
43
|
path: UrlGrammar.project_path(slug, "compute/llms/#{enc(model_id)}"))
|
|
37
44
|
end
|
|
38
45
|
|
|
39
|
-
# Synchronous inference
|
|
46
|
+
# Synchronous inference: POST /@<slug>/compute/llms/<id>/invoke { prompt, maxTokens?, temperature?,
|
|
47
|
+
# usageType? }. +params+ takes max_tokens / temperature / usage_type (the gateway's spellings also work).
|
|
48
|
+
# The envelope's data is { 'modelId', 'content', 'finishReason', 'usage' => { 'promptTokens',
|
|
49
|
+
# 'completionTokens', 'totalTokens' }, 'usageType' }.
|
|
40
50
|
def invoke(model_id, prompt:, params: nil)
|
|
41
51
|
slug = Studio.require_project_slug(@config, 'compute.llms')
|
|
42
|
-
body = { 'prompt' => prompt }
|
|
43
|
-
body['params'] = params if params
|
|
44
52
|
HttpTransport.request(config: @config, method: 'POST',
|
|
45
|
-
path: UrlGrammar.project_path(slug, "compute/llms/#{enc(model_id)}/invoke"),
|
|
53
|
+
path: UrlGrammar.project_path(slug, "compute/llms/#{enc(model_id)}/invoke"),
|
|
54
|
+
body: invoke_body(prompt, params))
|
|
46
55
|
end
|
|
47
56
|
|
|
48
|
-
# Streaming inference (SSE per-token). Returns an
|
|
49
|
-
# Enumerator of chunk hashes (Ruby idiom). Consume with
|
|
57
|
+
# Streaming inference (SSE per-token). Returns an Enumerator of chunk hashes. Consume with
|
|
50
58
|
# `.each { |chunk| ... }`.
|
|
51
59
|
def invoke_stream(model_id, prompt:, params: nil)
|
|
52
60
|
slug = Studio.require_project_slug(@config, 'compute.llms')
|
|
53
|
-
body = { 'prompt' => prompt }
|
|
54
|
-
body['params'] = params if params
|
|
55
61
|
HttpTransport.stream_sse(config: @config, method: 'POST',
|
|
56
62
|
path: UrlGrammar.project_path(slug, "compute/llms/#{enc(model_id)}/invoke-stream"),
|
|
57
|
-
body:
|
|
63
|
+
body: invoke_body(prompt, params))
|
|
58
64
|
end
|
|
59
65
|
|
|
60
66
|
private
|
|
61
67
|
|
|
68
|
+
def invoke_body(prompt, params)
|
|
69
|
+
body = { 'prompt' => prompt }
|
|
70
|
+
(params || {}).each do |key, value|
|
|
71
|
+
wire = PARAM_FIELDS[key.to_s]
|
|
72
|
+
raise ArgumentError, "unknown inference parameter: #{key}" if wire.nil?
|
|
73
|
+
|
|
74
|
+
body[wire] = value unless value.nil?
|
|
75
|
+
end
|
|
76
|
+
body
|
|
77
|
+
end
|
|
78
|
+
|
|
62
79
|
def enc(s)
|
|
63
80
|
::URI.encode_www_form_component(s)
|
|
64
81
|
end
|
data/lib/k9o/data.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
3
5
|
module K9o
|
|
4
6
|
# Data-plane DSL.
|
|
5
7
|
#
|
|
@@ -113,13 +115,77 @@ module K9o
|
|
|
113
115
|
end
|
|
114
116
|
|
|
115
117
|
class SelectBuilder < BaseBuilder
|
|
118
|
+
SEARCH_COLUMN = /\A[a-zA-Z_][a-zA-Z0-9_]{0,62}\z/.freeze
|
|
119
|
+
SEARCH_LANGUAGE = /\A[a-zA-Z_]+\z/.freeze
|
|
120
|
+
TEXT_TYPES = %w[plain phrase websearch].freeze
|
|
121
|
+
VECTOR_METRICS = %w[cosine euclidean dot].freeze
|
|
122
|
+
|
|
123
|
+
def initialize(config, collection)
|
|
124
|
+
super
|
|
125
|
+
@search = {}
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Full-text search over one text column. +type+ is "plain", "phrase" or "websearch".
|
|
129
|
+
def text_search(column, query, language: 'english', type: 'plain')
|
|
130
|
+
check_search_column(column)
|
|
131
|
+
raise search_error('text search query must be a non-empty string') unless query.is_a?(String) && !query.empty?
|
|
132
|
+
unless language.is_a?(String) && language.match?(SEARCH_LANGUAGE)
|
|
133
|
+
raise search_error('text search language must be letters and underscores only')
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
type = type.to_s
|
|
137
|
+
raise search_error("text search type must be one of #{TEXT_TYPES.join(', ')}") unless TEXT_TYPES.include?(type)
|
|
138
|
+
|
|
139
|
+
@search.delete('textSearchLanguage')
|
|
140
|
+
@search.delete('textSearchType')
|
|
141
|
+
@search['textSearch'] = "#{column}:#{query}"
|
|
142
|
+
@search['textSearchLanguage'] = language unless language == 'english'
|
|
143
|
+
@search['textSearchType'] = type unless type == 'plain'
|
|
144
|
+
self
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Nearest-neighbour search over one vector column; results come back closest first.
|
|
148
|
+
# +metric+ is "cosine", "euclidean" or "dot"; +limit+ is 1 to 1000 (server default 10).
|
|
149
|
+
def vector_search(column, embedding, metric: 'cosine', limit: nil)
|
|
150
|
+
check_search_column(column)
|
|
151
|
+
unless embedding.is_a?(Array) && !embedding.empty? &&
|
|
152
|
+
embedding.all? { |v| v.is_a?(Integer) || (v.is_a?(Float) && v.finite?) }
|
|
153
|
+
raise search_error('embedding must be a non-empty array of finite numbers')
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
metric = metric.to_s
|
|
157
|
+
raise search_error("vector metric must be one of #{VECTOR_METRICS.join(', ')}") unless VECTOR_METRICS.include?(metric)
|
|
158
|
+
unless limit.nil? || (limit.is_a?(Integer) && limit >= 1 && limit <= 1000)
|
|
159
|
+
raise search_error('vector limit must be an integer from 1 to 1000')
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
@search.delete('vectorMetric')
|
|
163
|
+
@search.delete('vectorLimit')
|
|
164
|
+
@search['vectorSearch'] = "#{column}:#{JSON.generate(embedding)}"
|
|
165
|
+
@search['vectorMetric'] = metric unless metric == 'cosine'
|
|
166
|
+
@search['vectorLimit'] = limit.to_s unless limit.nil?
|
|
167
|
+
self
|
|
168
|
+
end
|
|
169
|
+
|
|
116
170
|
def execute
|
|
117
171
|
HttpTransport.request(
|
|
118
172
|
config: @config, method: 'GET',
|
|
119
173
|
path: FromBuilder.collection_path(@config, @collection),
|
|
120
|
-
query: to_query
|
|
174
|
+
query: to_query.merge(@search)
|
|
121
175
|
)
|
|
122
176
|
end
|
|
177
|
+
|
|
178
|
+
private
|
|
179
|
+
|
|
180
|
+
def search_error(message)
|
|
181
|
+
KError.new(code: 'K9O_INVALID_FIELD_VALUE', message: message, context: { collection: @collection })
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def check_search_column(column)
|
|
185
|
+
return if column.is_a?(String) && column.match?(SEARCH_COLUMN)
|
|
186
|
+
|
|
187
|
+
raise search_error('search column must be a column name: letters, digits and underscores, starting with a letter or underscore')
|
|
188
|
+
end
|
|
123
189
|
end
|
|
124
190
|
|
|
125
191
|
class UpdateBuilder < BaseBuilder
|
data/lib/k9o/functions.rb
CHANGED
|
@@ -2,22 +2,52 @@
|
|
|
2
2
|
|
|
3
3
|
module K9o
|
|
4
4
|
# Function lifecycle namespace — k9o.functions.*.
|
|
5
|
+
#
|
|
6
|
+
# Upload the function's bytes first (+upload_artifact+ returns the +artifactKey+), then +deploy+ references that
|
|
7
|
+
# artifact. Deploy requires PROJECT_ADMIN and returns with status +deploying+; the function is ready when status
|
|
8
|
+
# flips to +active+.
|
|
5
9
|
class Functions
|
|
10
|
+
ARTIFACT_CONTENT_TYPES = { 'wasm' => 'application/wasm', 'tar' => 'application/x-tar' }.freeze
|
|
11
|
+
|
|
6
12
|
def initialize(config)
|
|
7
13
|
@config = config
|
|
8
14
|
end
|
|
9
15
|
|
|
10
|
-
|
|
16
|
+
# POST /@<slug>/functions/artifacts with the raw bytes: a WebAssembly module for the gateway tier (up to 16 MiB,
|
|
17
|
+
# +kind+ 'wasm') or an OCI image archive for the sandbox tier (up to 256 MiB, +kind+ 'tar'). Uploading the same
|
|
18
|
+
# bytes again returns the same key. The envelope's data is { 'artifactKey' => String }.
|
|
19
|
+
def upload_artifact(bytes, kind)
|
|
20
|
+
content_type = ARTIFACT_CONTENT_TYPES[kind.to_s]
|
|
21
|
+
raise ArgumentError, "kind must be 'wasm' or 'tar'" if content_type.nil?
|
|
22
|
+
|
|
23
|
+
bytes = bytes.read if bytes.respond_to?(:read)
|
|
24
|
+
raise ArgumentError, 'bytes must be a String or an IO' unless bytes.is_a?(String)
|
|
25
|
+
|
|
26
|
+
slug = require_project_slug
|
|
27
|
+
HttpTransport.request(config: @config, method: 'POST',
|
|
28
|
+
path: UrlGrammar.project_path(slug, 'functions/artifacts'),
|
|
29
|
+
raw_body: bytes, headers: { 'content-type' => content_type })
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# POST /@<slug>/functions — deploy or redeploy from an uploaded artifact.
|
|
33
|
+
# runtime: bun python go java kotlin dotnet swift php ruby rust tinygo zig assemblyscript
|
|
34
|
+
# tier: 'gateway' (WebAssembly) or 'sandbox' (OCI image archive)
|
|
35
|
+
# artifact_key: the key +upload_artifact+ returned; version: label for this deploy (1 to 64 characters)
|
|
36
|
+
def deploy(name:, runtime:, tier:, artifact_key:, version:, description: nil)
|
|
11
37
|
slug = require_project_slug
|
|
12
|
-
body = { 'name' => name, 'runtime' => runtime, '
|
|
13
|
-
|
|
14
|
-
body['
|
|
38
|
+
body = { 'name' => name, 'runtime' => runtime, 'tier' => tier, 'artifactKey' => artifact_key,
|
|
39
|
+
'version' => version }
|
|
40
|
+
body['description'] = description unless description.nil?
|
|
15
41
|
HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.project_path(slug, 'functions'), body: body)
|
|
16
42
|
end
|
|
17
43
|
|
|
18
|
-
|
|
44
|
+
# GET /@<slug>/functions (cursor pagination).
|
|
45
|
+
def list(status: nil, limit: nil, cursor: nil)
|
|
19
46
|
slug = require_project_slug
|
|
20
|
-
q =
|
|
47
|
+
q = {}
|
|
48
|
+
q['status'] = status unless status.nil?
|
|
49
|
+
q['limit'] = limit unless limit.nil?
|
|
50
|
+
q['cursor'] = cursor unless cursor.nil?
|
|
21
51
|
HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.project_path(slug, 'functions'), query: q)
|
|
22
52
|
end
|
|
23
53
|
|
|
@@ -26,16 +56,25 @@ module K9o
|
|
|
26
56
|
HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.project_path(slug, "functions/#{enc(name)}"))
|
|
27
57
|
end
|
|
28
58
|
|
|
59
|
+
# Retire a function; its name enters a 24h cooldown.
|
|
29
60
|
def delete(name)
|
|
30
61
|
slug = require_project_slug
|
|
31
62
|
HttpTransport.request(config: @config, method: 'DELETE', path: UrlGrammar.project_path(slug, "functions/#{enc(name)}"))
|
|
32
63
|
end
|
|
33
64
|
|
|
34
|
-
#
|
|
35
|
-
|
|
65
|
+
# POST /@<slug>/functions/<name>/invoke. +payload+ is sent as the +payload+ field of the request body and passed
|
|
66
|
+
# to the function runtime verbatim. Optional controls: +background+ (run asynchronously), +delegation+
|
|
67
|
+
# ({ 'token' => ..., 'invokeSessionId' => ... }), +on_behalf_of+, +confined_to+.
|
|
68
|
+
def invoke(name, payload = nil, background: nil, delegation: nil, on_behalf_of: nil, confined_to: nil)
|
|
36
69
|
slug = require_project_slug
|
|
70
|
+
body = {}
|
|
71
|
+
body['payload'] = payload unless payload.nil?
|
|
72
|
+
body['background'] = background unless background.nil?
|
|
73
|
+
body['delegation'] = delegation unless delegation.nil?
|
|
74
|
+
body['onBehalfOf'] = on_behalf_of unless on_behalf_of.nil?
|
|
75
|
+
body['confinedTo'] = confined_to unless confined_to.nil?
|
|
37
76
|
HttpTransport.request(config: @config, method: 'POST',
|
|
38
|
-
path: UrlGrammar.project_path(slug, "functions/#{enc(name)}/invoke"), body:
|
|
77
|
+
path: UrlGrammar.project_path(slug, "functions/#{enc(name)}/invoke"), body: body)
|
|
39
78
|
end
|
|
40
79
|
|
|
41
80
|
private
|
data/lib/k9o/gate.rb
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module K9o
|
|
4
|
-
# Gate (Identity-Aware Kernel) namespace. Project-scoped.
|
|
4
|
+
# Gate (Identity-Aware Kernel) namespace. Project-scoped; the gateway closes each body to the fields shown.
|
|
5
5
|
class Gate
|
|
6
6
|
def initialize(config)
|
|
7
7
|
@config = config
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
+
# POST /@<slug>/gate/signup { email, password, displayName? }
|
|
10
11
|
def sign_up(email:, password:, display_name: nil)
|
|
11
12
|
body = { 'email' => email, 'password' => password }
|
|
12
13
|
body['displayName'] = display_name if display_name
|
|
13
14
|
HttpTransport.request(config: @config, method: 'POST', path: gate_path('signup'), body: body)
|
|
14
15
|
end
|
|
15
16
|
|
|
17
|
+
# POST /@<slug>/gate/signin { email, password }
|
|
16
18
|
def sign_in(email:, password:)
|
|
17
19
|
HttpTransport.request(
|
|
18
20
|
config: @config, method: 'POST', path: gate_path('signin'),
|
|
@@ -20,15 +22,18 @@ module K9o
|
|
|
20
22
|
)
|
|
21
23
|
end
|
|
22
24
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
HttpTransport.request(config: @config, method: 'POST', path: gate_path('signout'),
|
|
25
|
+
# POST /@<slug>/gate/signout { refreshToken } — the refresh token is required (the gateway answers 400 without it).
|
|
26
|
+
def sign_out(refresh_token:)
|
|
27
|
+
HttpTransport.request(config: @config, method: 'POST', path: gate_path('signout'),
|
|
28
|
+
body: { 'refreshToken' => refresh_token })
|
|
26
29
|
end
|
|
27
30
|
|
|
31
|
+
# GET /@<slug>/gate/whoami
|
|
28
32
|
def whoami
|
|
29
33
|
HttpTransport.request(config: @config, method: 'GET', path: gate_path('whoami'))
|
|
30
34
|
end
|
|
31
35
|
|
|
36
|
+
# POST /@<slug>/gate/refresh { refreshToken }
|
|
32
37
|
def refresh_token(refresh_token:)
|
|
33
38
|
HttpTransport.request(
|
|
34
39
|
config: @config, method: 'POST', path: gate_path('refresh'),
|
|
@@ -36,20 +41,6 @@ module K9o
|
|
|
36
41
|
)
|
|
37
42
|
end
|
|
38
43
|
|
|
39
|
-
def verify_session
|
|
40
|
-
HttpTransport.request(config: @config, method: 'GET', path: gate_path('verify'))
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
# The project's hosted sign-in URL and WebAuthn rpId:
|
|
44
|
-
# GET /@<slug>/gate/hosted. Unauthenticated and cacheable; no credential is
|
|
45
|
-
# sent even when an api_key is configured. Returns an Envelope whose `data`
|
|
46
|
-
# is { 'signInUrl' => String, 'rpId' => String }. Take both values as given:
|
|
47
|
-
# an unknown org, a deleted project and a non-Gate project all answer the
|
|
48
|
-
# same 404 K9O_PROJECT_NOT_FOUND.
|
|
49
|
-
def hosted
|
|
50
|
-
HttpTransport.request(config: @config, method: 'GET', path: gate_path('hosted'), authenticated: false)
|
|
51
|
-
end
|
|
52
|
-
|
|
53
44
|
private
|
|
54
45
|
|
|
55
46
|
def gate_path(action)
|
data/lib/k9o/http_transport.rb
CHANGED
|
@@ -68,10 +68,11 @@ module K9o
|
|
|
68
68
|
#
|
|
69
69
|
# `authenticated: false` sends NO credential: no api_key bearer, and any
|
|
70
70
|
# authorization / cookie header from `default_headers` or `headers:` is dropped.
|
|
71
|
-
def request(config:, method:, path:, body: nil, query: nil, headers: nil, authenticated: true, multipart: nil
|
|
71
|
+
def request(config:, method:, path:, body: nil, query: nil, headers: nil, authenticated: true, multipart: nil,
|
|
72
|
+
raw_body: nil)
|
|
72
73
|
retry_with_backoff(method, config.retry_config || RetryConfig.default) do
|
|
73
74
|
request_once(config: config, method: method, path: path, body: body, query: query, headers: headers,
|
|
74
|
-
authenticated: authenticated, multipart: multipart)
|
|
75
|
+
authenticated: authenticated, multipart: multipart, raw_body: raw_body)
|
|
75
76
|
end
|
|
76
77
|
end
|
|
77
78
|
|
|
@@ -116,7 +117,8 @@ module K9o
|
|
|
116
117
|
end
|
|
117
118
|
|
|
118
119
|
# Single-attempt request. `request` wraps this in retry-with-backoff.
|
|
119
|
-
def request_once(config:, method:, path:, body: nil, query: nil, headers: nil, authenticated: true, multipart: nil
|
|
120
|
+
def request_once(config:, method:, path:, body: nil, query: nil, headers: nil, authenticated: true, multipart: nil,
|
|
121
|
+
raw_body: nil)
|
|
120
122
|
url = "#{Region.resolve_origin(config).sub(%r{/\z}, '')}#{path}"
|
|
121
123
|
if query && !query.empty?
|
|
122
124
|
url += '?' + URI.encode_www_form(query)
|
|
@@ -149,7 +151,10 @@ module K9o
|
|
|
149
151
|
req.delete('cookie')
|
|
150
152
|
end
|
|
151
153
|
|
|
152
|
-
if
|
|
154
|
+
if raw_body
|
|
155
|
+
# Binary routes: the bytes go unchanged; the caller supplies the content-type in +headers+.
|
|
156
|
+
req.body = raw_body
|
|
157
|
+
elsif multipart
|
|
153
158
|
# multipart/form-data: the encoder sets the boundary; never force JSON.
|
|
154
159
|
data, content_type = Multipart.encode(multipart)
|
|
155
160
|
req['content-type'] = content_type
|
|
@@ -215,7 +220,7 @@ module K9o
|
|
|
215
220
|
# sentinel terminates the stream; non-2xx raises KError before any
|
|
216
221
|
# event is yielded.
|
|
217
222
|
#
|
|
218
|
-
#
|
|
223
|
+
# The Ruby async-iteration idiom is Enumerator — caller
|
|
219
224
|
# writes `client.compute.llms.invoke_stream(...).each { |chunk| ... }`.
|
|
220
225
|
def stream_sse(config:, method:, path:, body: nil, query: nil)
|
|
221
226
|
url = "#{Region.resolve_origin(config).sub(%r{/\z}, '')}#{path}"
|