kward 0.79.0 → 0.80.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/.github/workflows/ci.yml +6 -0
- data/CHANGELOG.md +58 -1
- data/Gemfile.lock +2 -2
- data/README.md +31 -38
- data/doc/agent-tools.md +9 -10
- data/doc/api.md +3 -3
- data/doc/authentication.md +79 -110
- data/doc/code-search.md +1 -1
- data/doc/composer.md +1 -1
- data/doc/configuration.md +69 -26
- data/doc/context-budgeting.md +6 -6
- data/doc/context-tools.md +2 -2
- data/doc/editor.md +7 -7
- data/doc/extensibility.md +7 -29
- data/doc/files.md +6 -6
- data/doc/getting-started.md +2 -2
- data/doc/git.md +8 -2
- data/doc/lifecycle-hooks.md +5 -4
- data/doc/local-models.md +2 -2
- data/doc/mcp.md +5 -5
- data/doc/permissions.md +13 -4
- data/doc/plugins.md +39 -4
- data/doc/prompt-templates.md +105 -0
- data/doc/providers.md +140 -0
- data/doc/releasing.md +10 -2
- data/doc/rpc.md +54 -31
- data/doc/sandboxing.md +22 -0
- data/doc/security.md +22 -7
- data/doc/session-management.md +2 -4
- data/doc/tabs.md +37 -0
- data/doc/telegram.md +138 -0
- data/doc/transports.md +209 -0
- data/doc/usage.md +11 -3
- data/doc/web-search.md +3 -3
- data/doc/workspace-tools.md +2 -2
- data/examples/plugins/stardate_footer.rb +10 -0
- data/examples/plugins/telegram/plugin.rb +39 -0
- data/examples/plugins/telegram/telegram_api.rb +136 -0
- data/examples/plugins/telegram/telegram_transport.rb +304 -0
- data/lib/kward/ansi.rb +1 -0
- data/lib/kward/auth/api_key_store.rb +99 -0
- data/lib/kward/cli/auth_commands.rb +72 -13
- data/lib/kward/cli/commands.rb +7 -0
- data/lib/kward/cli/git.rb +31 -8
- data/lib/kward/cli/plugins.rb +1 -0
- data/lib/kward/cli/prompt_interface.rb +7 -0
- data/lib/kward/cli/rendering.rb +6 -2
- data/lib/kward/cli/runtime_helpers.rb +4 -2
- data/lib/kward/cli/sessions.rb +1 -1
- data/lib/kward/cli/settings.rb +97 -33
- data/lib/kward/cli/tabs.rb +102 -37
- data/lib/kward/cli/transports.rb +67 -0
- data/lib/kward/cli/worktrees.rb +360 -0
- data/lib/kward/cli.rb +20 -0
- data/lib/kward/config_files.rb +26 -3
- data/lib/kward/conversation.rb +13 -7
- data/lib/kward/git_worktree_manager.rb +291 -0
- data/lib/kward/model/azure_openai_config.rb +54 -0
- data/lib/kward/model/catalog.rb +113 -0
- data/lib/kward/model/client.rb +225 -20
- data/lib/kward/model/model_info.rb +49 -0
- data/lib/kward/model/payloads.rb +63 -4
- data/lib/kward/model/provider_catalog.rb +114 -0
- data/lib/kward/model/sources.rb +70 -0
- data/lib/kward/model/stream_parser.rb +83 -13
- data/lib/kward/permissions/policy.rb +18 -4
- data/lib/kward/plugin_chat_runtime.rb +374 -0
- data/lib/kward/plugin_registry.rb +80 -7
- data/lib/kward/private_file.rb +9 -3
- data/lib/kward/prompt_interface/approval_prompt.rb +2 -0
- data/lib/kward/prompt_interface/composer_renderer.rb +1 -5
- data/lib/kward/prompt_interface/editor/auto_indent.rb +46 -0
- data/lib/kward/prompt_interface/editor/buffer.rb +18 -4
- data/lib/kward/prompt_interface/editor/controller.rb +7 -7
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
- data/lib/kward/prompt_interface/editor/renderer.rb +8 -8
- data/lib/kward/prompt_interface/editor/state.rb +3 -9
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +184 -9
- data/lib/kward/prompt_interface/file_overlay.rb +2 -2
- data/lib/kward/prompt_interface/git_prompt.rb +7 -3
- data/lib/kward/prompt_interface/interactive/controller.rb +4 -4
- data/lib/kward/prompt_interface/interactive/renderer.rb +4 -1
- data/lib/kward/prompt_interface/interactive/state.rb +2 -2
- data/lib/kward/prompt_interface/project_browser.rb +25 -6
- data/lib/kward/prompt_interface/runtime_state.rb +22 -6
- data/lib/kward/prompt_interface/screen.rb +3 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +7 -1
- data/lib/kward/prompt_interface/transcript_buffer.rb +24 -2
- data/lib/kward/prompt_interface.rb +53 -12
- data/lib/kward/prompts/commands.rb +1 -1
- data/lib/kward/prompts.rb +6 -5
- data/lib/kward/rpc/auth_manager.rb +112 -152
- data/lib/kward/rpc/config_manager.rb +46 -6
- data/lib/kward/rpc/plugin_chat_manager.rb +55 -196
- data/lib/kward/rpc/prompt_bridge.rb +8 -4
- data/lib/kward/rpc/redactor.rb +1 -1
- data/lib/kward/rpc/server.rb +60 -10
- data/lib/kward/rpc/session_manager.rb +109 -30
- data/lib/kward/session_store.rb +84 -24
- data/lib/kward/tab_driver.rb +6 -3
- data/lib/kward/tools/git_commit.rb +39 -0
- data/lib/kward/tools/registry.rb +7 -2
- data/lib/kward/tools/tool_call.rb +1 -0
- data/lib/kward/transport/gateway.rb +253 -0
- data/lib/kward/transport/host.rb +343 -0
- data/lib/kward/transport/manager.rb +179 -0
- data/lib/kward/transport/plugin_chat_gateway.rb +186 -0
- data/lib/kward/transport/runtime.rb +41 -0
- data/lib/kward/transport/store.rb +101 -0
- data/lib/kward/transport.rb +204 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workspace_factory.rb +19 -2
- data/templates/default/fulldoc/html/js/kward.js +2 -0
- data/templates/default/kward_navigation.rb +7 -2
- data/templates/default/layout/html/setup.rb +4 -0
- metadata +26 -1
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "open3"
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
require_relative "path_guard"
|
|
6
|
+
|
|
7
|
+
# Namespace for the Kward CLI agent runtime.
|
|
8
|
+
module Kward
|
|
9
|
+
# Durable association between a session tab and a linked Git worktree.
|
|
10
|
+
class GitWorktreeBinding
|
|
11
|
+
FIELDS = %i[repository_root origin_root path branch base_revision active owned].freeze
|
|
12
|
+
|
|
13
|
+
attr_accessor :repository_root, :origin_root, :path, :branch, :base_revision, :active, :owned
|
|
14
|
+
|
|
15
|
+
def initialize(repository_root:, origin_root:, path:, branch:, base_revision:, active: true, owned: true)
|
|
16
|
+
@repository_root = repository_root.to_s
|
|
17
|
+
@origin_root = origin_root.to_s
|
|
18
|
+
@path = path.to_s
|
|
19
|
+
@branch = branch.to_s
|
|
20
|
+
@base_revision = base_revision.to_s
|
|
21
|
+
@active = active != false
|
|
22
|
+
@owned = owned != false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def active?
|
|
26
|
+
active == true
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def descriptor
|
|
30
|
+
{
|
|
31
|
+
"repository_root" => repository_root,
|
|
32
|
+
"origin_root" => origin_root,
|
|
33
|
+
"path" => path,
|
|
34
|
+
"branch" => branch,
|
|
35
|
+
"base_revision" => base_revision,
|
|
36
|
+
"active" => active?,
|
|
37
|
+
"owned" => owned != false
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.from_descriptor(value)
|
|
42
|
+
data = value.respond_to?(:transform_keys) ? value.transform_keys(&:to_s) : {}
|
|
43
|
+
required = %w[repository_root origin_root path branch base_revision]
|
|
44
|
+
return nil unless required.all? { |key| !data[key].to_s.empty? }
|
|
45
|
+
|
|
46
|
+
new(
|
|
47
|
+
repository_root: data["repository_root"],
|
|
48
|
+
origin_root: data["origin_root"],
|
|
49
|
+
path: data["path"],
|
|
50
|
+
branch: data["branch"],
|
|
51
|
+
base_revision: data["base_revision"],
|
|
52
|
+
active: data.fetch("active", true),
|
|
53
|
+
owned: data.fetch("owned", true)
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Performs safe, structured operations on linked Git worktrees.
|
|
59
|
+
class GitWorktreeManager
|
|
60
|
+
class Error < StandardError; end
|
|
61
|
+
|
|
62
|
+
WorktreeInfo = Struct.new(
|
|
63
|
+
:path,
|
|
64
|
+
:head,
|
|
65
|
+
:branch,
|
|
66
|
+
:detached,
|
|
67
|
+
:locked,
|
|
68
|
+
:prunable,
|
|
69
|
+
keyword_init: true
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
GitStatus = Struct.new(:entries, keyword_init: true) do
|
|
73
|
+
def clean?
|
|
74
|
+
entries.empty?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def dirty?
|
|
78
|
+
!clean?
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
MergeResult = Struct.new(:status, :output, :conflicts, keyword_init: true) do
|
|
83
|
+
def merged?
|
|
84
|
+
status == :merged
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def conflicted?
|
|
88
|
+
status == :conflicted
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def repository_root(path)
|
|
93
|
+
output = run_git(path, "rev-parse", "--show-toplevel")
|
|
94
|
+
canonical_existing_path(output.strip)
|
|
95
|
+
rescue Error
|
|
96
|
+
raise Error, "Not a Git repository: #{path}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def status(path)
|
|
100
|
+
output = run_git(path, "status", "--porcelain=v1", "--untracked-files=all")
|
|
101
|
+
GitStatus.new(entries: output.lines(chomp: true))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def current_revision(path)
|
|
105
|
+
run_git(path, "rev-parse", "HEAD").strip
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def current_branch(path)
|
|
109
|
+
run_git(path, "branch", "--show-current").strip
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def merge(repository_root:, target_path:, source_branch:)
|
|
113
|
+
repository_root = canonical_existing_path(repository_root)
|
|
114
|
+
target_path = canonical_existing_path(target_path)
|
|
115
|
+
source_branch = source_branch.to_s.strip
|
|
116
|
+
validate_merge!(repository_root, target_path, source_branch)
|
|
117
|
+
|
|
118
|
+
output, status = capture_git(target_path, "merge", "--no-edit", source_branch)
|
|
119
|
+
return MergeResult.new(status: :merged, output: output, conflicts: []) if status.success?
|
|
120
|
+
|
|
121
|
+
conflicts = unmerged_paths(target_path)
|
|
122
|
+
return MergeResult.new(status: :conflicted, output: output, conflicts: conflicts) if merge_in_progress?(target_path)
|
|
123
|
+
|
|
124
|
+
raise Error, git_error("merge", output)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def merge_in_progress?(path)
|
|
128
|
+
_output, status = capture_git(path, "rev-parse", "-q", "--verify", "MERGE_HEAD")
|
|
129
|
+
status.success?
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def unmerged_paths(path)
|
|
133
|
+
run_git(path, "diff", "--name-only", "--diff-filter=U").lines(chomp: true)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def abort_merge(path)
|
|
137
|
+
raise Error, "No merge is in progress." unless merge_in_progress?(path)
|
|
138
|
+
|
|
139
|
+
run_git(path, "merge", "--abort")
|
|
140
|
+
true
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def create(repository_root:, origin_root:, path:, branch:, base: "HEAD")
|
|
144
|
+
repository_root = canonical_existing_path(repository_root)
|
|
145
|
+
origin_root = canonical_existing_path(origin_root)
|
|
146
|
+
path = planned_path(path)
|
|
147
|
+
branch = branch.to_s.strip
|
|
148
|
+
base = base.to_s.strip
|
|
149
|
+
validate_creation!(repository_root, origin_root, path, branch, base)
|
|
150
|
+
|
|
151
|
+
run_git(repository_root, "worktree", "add", "-b", branch, path, base)
|
|
152
|
+
GitWorktreeBinding.new(
|
|
153
|
+
repository_root: repository_root,
|
|
154
|
+
origin_root: origin_root,
|
|
155
|
+
path: path,
|
|
156
|
+
branch: branch,
|
|
157
|
+
base_revision: revision_for(repository_root, base),
|
|
158
|
+
active: true,
|
|
159
|
+
owned: true
|
|
160
|
+
)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def inspect(repository_root:, path:)
|
|
164
|
+
repository_root = canonical_existing_path(repository_root)
|
|
165
|
+
expected_path = planned_path(path)
|
|
166
|
+
worktree = list(repository_root).find { |entry| same_path?(entry.path, expected_path) }
|
|
167
|
+
raise Error, "Git worktree is not registered: #{expected_path}" unless worktree
|
|
168
|
+
|
|
169
|
+
worktree
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def list(repository_root)
|
|
173
|
+
output = run_git(repository_root, "worktree", "list", "--porcelain")
|
|
174
|
+
parse_worktree_list(output)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def remove(repository_root:, path:, force: false)
|
|
178
|
+
repository_root = canonical_existing_path(repository_root)
|
|
179
|
+
expected_path = planned_path(path)
|
|
180
|
+
inspect(repository_root: repository_root, path: expected_path)
|
|
181
|
+
arguments = ["worktree", "remove"]
|
|
182
|
+
arguments << "--force" if force
|
|
183
|
+
arguments << expected_path
|
|
184
|
+
run_git(repository_root, *arguments)
|
|
185
|
+
true
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
private
|
|
189
|
+
|
|
190
|
+
def validate_merge!(repository_root, target_path, source_branch)
|
|
191
|
+
raise Error, "Merge target must be inside the repository: #{target_path}" unless PathGuard.inside?(target_path, repository_root)
|
|
192
|
+
raise Error, "Source branch is required" if source_branch.empty?
|
|
193
|
+
raise Error, "Invalid source branch: #{source_branch}" unless git_succeeds?(repository_root, "check-ref-format", "--branch", source_branch)
|
|
194
|
+
raise Error, "Source branch does not exist: #{source_branch}" unless valid_revision?(repository_root, source_branch)
|
|
195
|
+
raise Error, "A merge is already in progress: #{target_path}" if merge_in_progress?(target_path)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def validate_creation!(repository_root, origin_root, path, branch, base)
|
|
199
|
+
raise Error, "Worktree path must be outside the repository: #{path}" if PathGuard.inside?(path, repository_root)
|
|
200
|
+
raise Error, "Worktree origin must be inside the repository: #{origin_root}" unless PathGuard.inside?(origin_root, repository_root)
|
|
201
|
+
raise Error, "Branch name is required" if branch.empty?
|
|
202
|
+
raise Error, "Base revision is required" if base.empty?
|
|
203
|
+
raise Error, "Invalid branch name: #{branch}" unless git_succeeds?(repository_root, "check-ref-format", "--branch", branch)
|
|
204
|
+
raise Error, "Invalid base revision: #{base}" unless valid_revision?(repository_root, base)
|
|
205
|
+
raise Error, "Worktree path is a file: #{path}" if File.file?(path)
|
|
206
|
+
raise Error, "Worktree path is not empty: #{path}" if File.directory?(path) && !Dir.empty?(path)
|
|
207
|
+
raise Error, "Worktree path is already registered: #{path}" if list(repository_root).any? { |entry| same_path?(entry.path, path) }
|
|
208
|
+
|
|
209
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def valid_revision?(repository_root, revision)
|
|
213
|
+
return false if revision.start_with?("-") || revision.match?(/\s/)
|
|
214
|
+
|
|
215
|
+
git_succeeds?(repository_root, "rev-parse", "--verify", "#{revision}^{commit}")
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def revision_for(repository_root, revision)
|
|
219
|
+
run_git(repository_root, "rev-parse", "--verify", "#{revision}^{commit}").strip
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def parse_worktree_list(output)
|
|
223
|
+
output.split(/\n\n+/).filter_map do |block|
|
|
224
|
+
fields = block.lines(chomp: true).each_with_object({}) do |line, values|
|
|
225
|
+
key, value = line.split(" ", 2)
|
|
226
|
+
values[key] = value.to_s unless key.nil? || key.empty?
|
|
227
|
+
end
|
|
228
|
+
path = fields["worktree"]
|
|
229
|
+
next if path.to_s.empty?
|
|
230
|
+
|
|
231
|
+
branch = fields["branch"].to_s.delete_prefix("refs/heads/")
|
|
232
|
+
WorktreeInfo.new(
|
|
233
|
+
path: path,
|
|
234
|
+
head: fields["HEAD"].to_s,
|
|
235
|
+
branch: branch.empty? ? nil : branch,
|
|
236
|
+
detached: fields.key?("detached"),
|
|
237
|
+
locked: fields.key?("locked"),
|
|
238
|
+
prunable: fields.key?("prunable")
|
|
239
|
+
)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def run_git(root, *arguments)
|
|
244
|
+
output, status = capture_git(root, *arguments)
|
|
245
|
+
return output if status.success?
|
|
246
|
+
|
|
247
|
+
raise Error, git_error(arguments.join(" "), output)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def capture_git(root, *arguments)
|
|
251
|
+
Open3.capture2e("git", "-C", root.to_s, *arguments)
|
|
252
|
+
rescue Errno::ENOENT => e
|
|
253
|
+
raise Error, e.message
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def git_error(command, output)
|
|
257
|
+
message = output.to_s.strip
|
|
258
|
+
message = "git #{command} failed" if message.empty?
|
|
259
|
+
message
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def git_succeeds?(root, *arguments)
|
|
263
|
+
_output, status = Open3.capture2e("git", "-C", root.to_s, *arguments)
|
|
264
|
+
status.success?
|
|
265
|
+
rescue Errno::ENOENT
|
|
266
|
+
false
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def canonical_existing_path(path)
|
|
270
|
+
File.realpath(path.to_s)
|
|
271
|
+
rescue Errno::ENOENT, Errno::ENOTDIR
|
|
272
|
+
raise Error, "Path does not exist: #{path}"
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def planned_path(path)
|
|
276
|
+
expanded = Pathname.new(path.to_s).expand_path
|
|
277
|
+
return File.realpath(expanded.to_s) if File.exist?(expanded.to_s)
|
|
278
|
+
|
|
279
|
+
ancestor = expanded
|
|
280
|
+
ancestor = ancestor.parent until File.exist?(ancestor.to_s) || ancestor == ancestor.parent
|
|
281
|
+
canonical_ancestor = Pathname.new(File.realpath(ancestor.to_s))
|
|
282
|
+
canonical_ancestor.join(expanded.relative_path_from(ancestor)).to_s
|
|
283
|
+
rescue ArgumentError, Errno::ENOENT, Errno::ENOTDIR
|
|
284
|
+
raise Error, "Invalid worktree path: #{path}"
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def same_path?(left, right)
|
|
288
|
+
planned_path(left) == planned_path(right)
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
# Namespace for Kward model-provider support.
|
|
4
|
+
module Kward
|
|
5
|
+
# Validated Azure OpenAI endpoint and deployment settings.
|
|
6
|
+
class AzureOpenAIConfig
|
|
7
|
+
DEPLOYMENT_PATTERN = /\A[a-zA-Z0-9._-]+\z/
|
|
8
|
+
API_VERSION_PATTERN = /\A[a-zA-Z0-9._-]+\z/
|
|
9
|
+
|
|
10
|
+
def initialize(endpoint:, deployment:, api_version:)
|
|
11
|
+
@endpoint = normalize_endpoint(endpoint)
|
|
12
|
+
@deployment = validate_value(deployment, "deployment name", DEPLOYMENT_PATTERN)
|
|
13
|
+
@api_version = validate_value(api_version, "API version", API_VERSION_PATTERN)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
attr_reader :endpoint, :deployment, :api_version
|
|
17
|
+
|
|
18
|
+
def chat_completions_url
|
|
19
|
+
URI("#{endpoint}/openai/deployments/#{deployment}/chat/completions?api-version=#{api_version}")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def to_config
|
|
23
|
+
{
|
|
24
|
+
"azure_openai_endpoint" => endpoint,
|
|
25
|
+
"azure_openai_model" => deployment,
|
|
26
|
+
"azure_openai_api_version" => api_version
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def normalize_endpoint(value)
|
|
33
|
+
text = value.to_s.strip
|
|
34
|
+
raise ArgumentError, "Azure OpenAI endpoint must be a non-empty HTTPS URL" if text.empty?
|
|
35
|
+
|
|
36
|
+
uri = URI.parse(text)
|
|
37
|
+
unless uri.is_a?(URI::HTTPS) && !uri.host.to_s.empty? && uri.user.nil? && uri.password.nil? && uri.query.nil? && uri.fragment.nil?
|
|
38
|
+
raise ArgumentError, "Azure OpenAI endpoint must be an HTTPS URL without credentials, query, or fragment"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
uri.to_s.sub(%r{/+\z}, "")
|
|
42
|
+
rescue URI::InvalidURIError
|
|
43
|
+
raise ArgumentError, "Azure OpenAI endpoint must be a valid HTTPS URL"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def validate_value(value, label, pattern)
|
|
47
|
+
text = value.to_s.strip
|
|
48
|
+
raise ArgumentError, "Azure OpenAI #{label} must be a non-empty string" if text.empty?
|
|
49
|
+
raise ArgumentError, "Azure OpenAI #{label} contains invalid characters" unless text.match?(pattern)
|
|
50
|
+
|
|
51
|
+
text
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "time"
|
|
4
|
+
require "uri"
|
|
5
|
+
require_relative "../config_files"
|
|
6
|
+
require_relative "../http"
|
|
7
|
+
require_relative "../private_file"
|
|
8
|
+
require_relative "provider_catalog"
|
|
9
|
+
|
|
10
|
+
# Namespace for Kward model catalogs.
|
|
11
|
+
module Kward
|
|
12
|
+
# Fetches, normalizes, and caches provider model lists without storing secrets.
|
|
13
|
+
class ModelCatalog
|
|
14
|
+
VERSION = 1
|
|
15
|
+
attr_reader :path
|
|
16
|
+
|
|
17
|
+
def initialize(provider_id:, api_key:, path: nil, requester: nil)
|
|
18
|
+
@provider = ProviderCatalog.fetch(provider_id)
|
|
19
|
+
@api_key = api_key.to_s
|
|
20
|
+
@path = File.expand_path(path || ConfigFiles.model_catalog_cache_path(@provider.id))
|
|
21
|
+
@requester = requester
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def refresh
|
|
25
|
+
raise "No #{@provider.name} API key found" if @api_key.empty?
|
|
26
|
+
raise "#{@provider.name} does not support automatic model discovery" unless runtime.automatic_model_discovery?
|
|
27
|
+
|
|
28
|
+
data = cache_data(normalize(response_body))
|
|
29
|
+
PrivateFile.write_json(path, data)
|
|
30
|
+
data.fetch("models")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def models
|
|
34
|
+
Array(read&.fetch("models", []))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def read
|
|
38
|
+
return nil unless File.exist?(path)
|
|
39
|
+
|
|
40
|
+
data = JSON.parse(File.read(path))
|
|
41
|
+
return nil unless data.is_a?(Hash) && data["version"] == VERSION && data["provider"] == @provider.id
|
|
42
|
+
|
|
43
|
+
data
|
|
44
|
+
rescue JSON::ParserError
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def response_body
|
|
51
|
+
return @requester.call(url, headers) if @requester
|
|
52
|
+
|
|
53
|
+
response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) do |http|
|
|
54
|
+
http.request(Http.apply_user_agent(Net::HTTP::Get.new(url)).tap do |request|
|
|
55
|
+
headers.each { |name, value| request[name] = value }
|
|
56
|
+
end)
|
|
57
|
+
end
|
|
58
|
+
raise "#{@provider.name} model refresh failed: #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
59
|
+
|
|
60
|
+
response.body
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def url
|
|
64
|
+
base = runtime.model_list_url
|
|
65
|
+
return URI("#{base}?key=#{URI.encode_www_form_component(@api_key)}") if @provider.id == "gemini"
|
|
66
|
+
|
|
67
|
+
URI(base)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def runtime
|
|
71
|
+
ProviderCatalog.runtime(@provider.id)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def headers
|
|
75
|
+
return { "x-api-key" => @api_key, "anthropic-version" => "2023-06-01" } if @provider.id == "anthropic"
|
|
76
|
+
return {} if @provider.id == "gemini"
|
|
77
|
+
|
|
78
|
+
{ "Authorization" => "Bearer #{@api_key}", "Accept" => "application/json" }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def normalize(body)
|
|
82
|
+
payload = JSON.parse(body.to_s)
|
|
83
|
+
entries = payload["data"] || payload["models"] || []
|
|
84
|
+
Array(entries).filter_map { |entry| normalize_entry(entry) }.sort_by { |entry| entry["id"] }
|
|
85
|
+
rescue JSON::ParserError
|
|
86
|
+
raise "#{@provider.name} model refresh returned invalid JSON"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def normalize_entry(entry)
|
|
90
|
+
return unless entry.is_a?(Hash)
|
|
91
|
+
|
|
92
|
+
id = (entry["id"] || entry["name"]).to_s.delete_prefix("models/")
|
|
93
|
+
return if id.empty?
|
|
94
|
+
|
|
95
|
+
{
|
|
96
|
+
"provider" => @provider.id,
|
|
97
|
+
"id" => id,
|
|
98
|
+
"name" => entry["display_name"] || entry["displayName"] || entry["name"] || id,
|
|
99
|
+
"contextWindow" => entry["context_window"] || entry["context_length"] || entry["inputTokenLimit"],
|
|
100
|
+
"supportedParameters" => Array(entry["supported_parameters"] || entry["supportedGenerationMethods"])
|
|
101
|
+
}.compact
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def cache_data(models)
|
|
105
|
+
{
|
|
106
|
+
"version" => VERSION,
|
|
107
|
+
"provider" => @provider.id,
|
|
108
|
+
"refreshed_at" => Time.now.utc.iso8601,
|
|
109
|
+
"models" => models
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|