socaity-cli 0.1.7__tar.gz → 0.1.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. {socaity_cli-0.1.7 → socaity_cli-0.1.8}/PKG-INFO +5 -5
  2. {socaity_cli-0.1.7 → socaity_cli-0.1.8}/README.md +3 -3
  3. {socaity_cli-0.1.7 → socaity_cli-0.1.8}/pyproject.toml +2 -2
  4. {socaity_cli-0.1.7 → socaity_cli-0.1.8}/socaity_cli/backend_client.py +7 -1
  5. {socaity_cli-0.1.7 → socaity_cli-0.1.8}/socaity_cli/cli.py +203 -4
  6. {socaity_cli-0.1.7 → socaity_cli-0.1.8}/socaity_cli/clients/catalog.py +121 -4
  7. socaity_cli-0.1.8/socaity_cli/clients/conversations.py +81 -0
  8. socaity_cli-0.1.8/socaity_cli/clients/files.py +156 -0
  9. socaity_cli-0.1.8/socaity_cli/clients/interrupts.py +61 -0
  10. {socaity_cli-0.1.7 → socaity_cli-0.1.8}/socaity_cli/clients/jobs.py +16 -1
  11. socaity_cli-0.1.8/socaity_cli/clients/workflows.py +249 -0
  12. {socaity_cli-0.1.7 → socaity_cli-0.1.8}/socaity_cli/credentials.py +14 -8
  13. {socaity_cli-0.1.7 → socaity_cli-0.1.8}/socaity_cli/deploy_messages.py +2 -0
  14. {socaity_cli-0.1.7 → socaity_cli-0.1.8}/socaity_cli/http.py +18 -3
  15. {socaity_cli-0.1.7 → socaity_cli-0.1.8}/socaity_cli/sdk_commands.py +27 -0
  16. {socaity_cli-0.1.7 → socaity_cli-0.1.8}/socaity_cli.egg-info/PKG-INFO +5 -5
  17. {socaity_cli-0.1.7 → socaity_cli-0.1.8}/socaity_cli.egg-info/SOURCES.txt +3 -0
  18. {socaity_cli-0.1.7 → socaity_cli-0.1.8}/socaity_cli.egg-info/requires.txt +1 -1
  19. socaity_cli-0.1.7/socaity_cli/clients/files.py +0 -82
  20. {socaity_cli-0.1.7 → socaity_cli-0.1.8}/setup.cfg +0 -0
  21. {socaity_cli-0.1.7 → socaity_cli-0.1.8}/socaity_cli/__init__.py +0 -0
  22. {socaity_cli-0.1.7 → socaity_cli-0.1.8}/socaity_cli/__main__.py +0 -0
  23. {socaity_cli-0.1.7 → socaity_cli-0.1.8}/socaity_cli/apipod_commands.py +0 -0
  24. {socaity_cli-0.1.7 → socaity_cli-0.1.8}/socaity_cli/auth.py +0 -0
  25. {socaity_cli-0.1.7 → socaity_cli-0.1.8}/socaity_cli/clients/__init__.py +0 -0
  26. {socaity_cli-0.1.7 → socaity_cli-0.1.8}/socaity_cli/clients/analytics.py +0 -0
  27. {socaity_cli-0.1.7 → socaity_cli-0.1.8}/socaity_cli/clients/deployment.py +0 -0
  28. {socaity_cli-0.1.7 → socaity_cli-0.1.8}/socaity_cli/clients/projects.py +0 -0
  29. {socaity_cli-0.1.7 → socaity_cli-0.1.8}/socaity_cli/clients/sdk_install.py +0 -0
  30. {socaity_cli-0.1.7 → socaity_cli-0.1.8}/socaity_cli/deployment.py +0 -0
  31. {socaity_cli-0.1.7 → socaity_cli-0.1.8}/socaity_cli/errors.py +0 -0
  32. {socaity_cli-0.1.7 → socaity_cli-0.1.8}/socaity_cli/prompts.py +0 -0
  33. {socaity_cli-0.1.7 → socaity_cli-0.1.8}/socaity_cli/registry_push.py +0 -0
  34. {socaity_cli-0.1.7 → socaity_cli-0.1.8}/socaity_cli/requires.py +0 -0
  35. {socaity_cli-0.1.7 → socaity_cli-0.1.8}/socaity_cli.egg-info/dependency_links.txt +0 -0
  36. {socaity_cli-0.1.7 → socaity_cli-0.1.8}/socaity_cli.egg-info/entry_points.txt +0 -0
  37. {socaity_cli-0.1.7 → socaity_cli-0.1.8}/socaity_cli.egg-info/top_level.txt +0 -0
  38. {socaity_cli-0.1.7 → socaity_cli-0.1.8}/test/test_cli.py +0 -0
  39. {socaity_cli-0.1.7 → socaity_cli-0.1.8}/test/test_credentials.py +0 -0
  40. {socaity_cli-0.1.7 → socaity_cli-0.1.8}/test/test_private_slot_error.py +0 -0
  41. {socaity_cli-0.1.7 → socaity_cli-0.1.8}/test/test_registry_push.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: socaity-cli
3
- Version: 0.1.7
3
+ Version: 0.1.8
4
4
  Summary: Socaity platform CLI: login, catalog browsing, SDK installs and APIPod deployment commands.
5
5
  Author: SocAIty
6
6
  License: MIT
@@ -9,7 +9,7 @@ Project-URL: Homepage, https://www.socaity.ai
9
9
  Requires-Python: >=3.8
10
10
  Description-Content-Type: text/markdown
11
11
  Requires-Dist: httpx
12
- Requires-Dist: socaity-schemas>=0.1.4
12
+ Requires-Dist: socaity-schemas>=0.1.6
13
13
  Provides-Extra: dev
14
14
  Requires-Dist: pytest; extra == "dev"
15
15
  Requires-Dist: ruff; extra == "dev"
@@ -49,15 +49,15 @@ You get `socaity-cli` as part of `pip install socaity` or `pip install apipod` a
49
49
  | `socaity start [entrypoint]` | `apipod` | Run an APIPod service locally |
50
50
  | `socaity deploy [target]` | `apipod` | Deploy to Socaity (wraps `apipod deploy`) |
51
51
 
52
- Use `-C DIR` with scan/build/start/deploy to point at a service project directory
53
- (e.g. `socaity -C simple_test_service deploy serverless-runpod --yes`).
52
+ Use `--project-dir DIR` with scan/build/start/deploy to point at a service project directory
53
+ (e.g. `socaity --project-dir simple_test_service deploy serverless-runpod --yes`).
54
54
 
55
55
  ## Configuration
56
56
 
57
57
  | Env var | Default | Purpose |
58
58
  |---------|---------|---------|
59
59
  | `SOCAITY_API_KEY` | from login | API key (wins over stored credentials) |
60
- | `SOCAITY_BACKEND_URL` | `https://webapi.socaity.ai` | Platform API |
60
+ | `SOCAITY_BACKEND_URL` | `https://webapi.socaity.ai` | Platform API (env wins; login file is fallback only, never written into the process) |
61
61
  | `SOCAITY_FRONTEND_URL` | `https://socaity.ai` | Web UI for browser login |
62
62
 
63
63
  ## Using it as a library
@@ -32,15 +32,15 @@ You get `socaity-cli` as part of `pip install socaity` or `pip install apipod` a
32
32
  | `socaity start [entrypoint]` | `apipod` | Run an APIPod service locally |
33
33
  | `socaity deploy [target]` | `apipod` | Deploy to Socaity (wraps `apipod deploy`) |
34
34
 
35
- Use `-C DIR` with scan/build/start/deploy to point at a service project directory
36
- (e.g. `socaity -C simple_test_service deploy serverless-runpod --yes`).
35
+ Use `--project-dir DIR` with scan/build/start/deploy to point at a service project directory
36
+ (e.g. `socaity --project-dir simple_test_service deploy serverless-runpod --yes`).
37
37
 
38
38
  ## Configuration
39
39
 
40
40
  | Env var | Default | Purpose |
41
41
  |---------|---------|---------|
42
42
  | `SOCAITY_API_KEY` | from login | API key (wins over stored credentials) |
43
- | `SOCAITY_BACKEND_URL` | `https://webapi.socaity.ai` | Platform API |
43
+ | `SOCAITY_BACKEND_URL` | `https://webapi.socaity.ai` | Platform API (env wins; login file is fallback only, never written into the process) |
44
44
  | `SOCAITY_FRONTEND_URL` | `https://socaity.ai` | Web UI for browser login |
45
45
 
46
46
  ## Using it as a library
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "socaity-cli"
7
- version = "0.1.7"
7
+ version = "0.1.8"
8
8
  description = "Socaity platform CLI: login, catalog browsing, SDK installs and APIPod deployment commands."
9
9
  requires-python = ">=3.8"
10
10
  authors = [
@@ -14,7 +14,7 @@ license = { text = "MIT" }
14
14
  readme = "README.md"
15
15
  dependencies = [
16
16
  "httpx",
17
- "socaity-schemas>=0.1.4",
17
+ "socaity-schemas>=0.1.6",
18
18
  ]
19
19
 
20
20
  [project.scripts]
@@ -1,11 +1,14 @@
1
- """Composed platform backend client (catalog, jobs, projects, analytics, files, sdk, deployment)."""
1
+ """Composed platform backend client (catalog, jobs, projects, analytics, files, sdk, deployment, workflows)."""
2
2
  from socaity_cli.clients.analytics import AnalyticsClient
3
3
  from socaity_cli.clients.catalog import CatalogClient
4
+ from socaity_cli.clients.conversations import ConversationsClient
4
5
  from socaity_cli.clients.deployment import DeploymentClient
5
6
  from socaity_cli.clients.files import FilesClient
7
+ from socaity_cli.clients.interrupts import InterruptsClient
6
8
  from socaity_cli.clients.jobs import JobsClient
7
9
  from socaity_cli.clients.projects import ProjectsClient
8
10
  from socaity_cli.clients.sdk_install import SdkInstallClient
11
+ from socaity_cli.clients.workflows import WorkflowsClient
9
12
  from socaity_cli.http import BackendHttp
10
13
 
11
14
 
@@ -18,6 +21,9 @@ class SocaityBackendClient(
18
21
  FilesClient,
19
22
  SdkInstallClient,
20
23
  DeploymentClient,
24
+ ConversationsClient,
25
+ InterruptsClient,
26
+ WorkflowsClient,
21
27
  ):
22
28
  """Facade over domain mixins. Prefer importing a domain client when you only need one surface."""
23
29
  pass
@@ -3,12 +3,14 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import argparse
6
+ import json
7
+ from pathlib import Path
6
8
 
7
9
  from socaity_cli.apipod_commands import run_build, run_deploy, run_scan, run_start
8
10
  from socaity_cli.auth import run_login, run_whoami
9
11
  from socaity_cli.backend_client import SocaityBackendClient
10
12
  from socaity_cli.credentials import clear_credentials, require_credentials
11
- from socaity_cli.sdk_commands import run_install, run_update
13
+ from socaity_cli.sdk_commands import run_install, run_update, run_workflow_command
12
14
 
13
15
 
14
16
  def _run_list(entity: str, category: str | None, family: str | None, limit: int) -> None:
@@ -75,6 +77,127 @@ def _run_jobs(
75
77
  _print_job(job)
76
78
 
77
79
 
80
+ def _run_interrupts(
81
+ interrupt_id: str | None,
82
+ resolve: str | None,
83
+ message: str | None,
84
+ expand: str | None,
85
+ status: str | None,
86
+ limit: int,
87
+ no_continue: bool = False,
88
+ ) -> None:
89
+ client = SocaityBackendClient()
90
+ expand_list = _split_csv(expand)
91
+
92
+ if interrupt_id and resolve:
93
+ result = client.resolve_interrupt(
94
+ interrupt_id, decision=resolve, message=message, continue_run=not no_continue,
95
+ )
96
+ if not result:
97
+ print(f"Interrupt '{interrupt_id}' not found.")
98
+ return
99
+ print(result.model_dump_json(indent=2, exclude_none=True))
100
+ return
101
+
102
+ if interrupt_id:
103
+ interrupt = client.get_interrupt(interrupt_id, expand=expand_list)
104
+ if not interrupt:
105
+ print(f"Interrupt '{interrupt_id}' not found.")
106
+ return
107
+ print(interrupt.model_dump_json(indent=2, exclude_none=True))
108
+ return
109
+
110
+ filters = [f"status:eq:{status}"] if status else None
111
+ interrupts = client.query_interrupts(filters=filters, expand=expand_list, limit=limit)
112
+ if not interrupts:
113
+ print("No pending interrupts. Your agents are not waiting on you.")
114
+ return
115
+ for row in interrupts:
116
+ decisions = "|".join(row.allowed_decisions or [])
117
+ print(f"{row.id} {row.action_name} [{row.status}] ({decisions}) {row.description or ''}".rstrip())
118
+
119
+
120
+ def _parse_inputs(pairs: list[str] | None) -> dict:
121
+ """Parse repeatable ``--input key=value`` flags. Values parse as JSON when possible."""
122
+ inputs: dict = {}
123
+ for pair in pairs or []:
124
+ key, separator, value = pair.partition("=")
125
+ if not separator or not key.strip():
126
+ print(f"Ignoring malformed --input '{pair}' (expected key=value).")
127
+ continue
128
+ try:
129
+ inputs[key.strip()] = json.loads(value)
130
+ except json.JSONDecodeError:
131
+ inputs[key.strip()] = value
132
+ return inputs
133
+
134
+
135
+ def _run_workflows(args) -> None:
136
+ client = SocaityBackendClient()
137
+ command = args.workflows_command
138
+
139
+ if command == "list":
140
+ workflows = client.search_workflows(query=args.q, limit=args.limit)
141
+ if not workflows:
142
+ print("No workflows found.")
143
+ return
144
+ for workflow in workflows:
145
+ visibility = "public" if workflow.is_public else "private"
146
+ print(f"{workflow.id} {workflow.slug or '-'} [{visibility}] {workflow.title or ''}".rstrip())
147
+ return
148
+
149
+ if command == "get":
150
+ workflow = client.get_workflow(args.workflow_id)
151
+ if not workflow:
152
+ print(f"Workflow '{args.workflow_id}' not found.")
153
+ return
154
+ print(workflow.model_dump_json(indent=2, exclude_none=True))
155
+ return
156
+
157
+ if command == "push":
158
+ path = Path(args.file)
159
+ if not path.is_file():
160
+ print(f"File '{args.file}' not found.")
161
+ return
162
+ document = json.loads(path.read_text(encoding="utf-8"))
163
+ result = client.upsert_workflow(document, slug=args.slug, message=args.message)
164
+ if not result:
165
+ print("Workflow upsert failed.")
166
+ return
167
+ state = "unchanged" if result.unchanged else ("forked" if result.forked else "saved")
168
+ print(f"{result.workflow.id} revision {result.revision.id} [{state}]")
169
+ return
170
+
171
+ if command == "publish":
172
+ workflow = client.publish_workflow(args.workflow_id, revision_id=args.revision_id)
173
+ if not workflow:
174
+ print(f"Workflow '{args.workflow_id}' not found or not publishable.")
175
+ return
176
+ print(f"{workflow.id} published revision {workflow.published_revision_id}")
177
+ return
178
+
179
+ if command == "runs":
180
+ if args.workflow_id and args.workflow_id.startswith("wr_"):
181
+ run = client.get_workflow_run(args.workflow_id, expand=["traces"])
182
+ if not run:
183
+ print(f"Workflow run '{args.workflow_id}' not found.")
184
+ return
185
+ print(run.model_dump_json(indent=2, exclude_none=True))
186
+ return
187
+ runs = client.query_workflow_runs(workflow_id=args.workflow_id, limit=args.limit)
188
+ if not runs:
189
+ print("No workflow runs found.")
190
+ return
191
+ for run in runs:
192
+ cost = f" {run.total_cost_eur:.4f} EUR" if run.total_cost_eur is not None else ""
193
+ print(f"{run.id} [{run.status}] {run.workflow_id or '?'} {run.created_at or ''}{cost}")
194
+ return
195
+
196
+ if command == "run":
197
+ run_workflow_command(args.workflow_id, _parse_inputs(args.input), timeout_s=args.timeout)
198
+ return
199
+
200
+
78
201
  def _run_projects(project_id: str | None, expand: str | None, limit: int) -> None:
79
202
  client = SocaityBackendClient()
80
203
  expand_list = _split_csv(expand)
@@ -137,7 +260,7 @@ Examples:
137
260
  socaity build main.py
138
261
  socaity start --port 8123
139
262
  socaity deploy serverless-runpod
140
- socaity -C simple_test_service deploy serverless-runpod --yes
263
+ socaity --project-dir simple_test_service deploy serverless-runpod --yes
141
264
  """,
142
265
  )
143
266
  parser.add_argument(
@@ -146,12 +269,19 @@ Examples:
146
269
  help="Install an AI service (requires login)",
147
270
  )
148
271
  parser.add_argument(
149
- "-C",
150
272
  "--project-dir",
273
+ dest="project_dir",
151
274
  default=None,
152
275
  metavar="DIR",
153
276
  help="Service project directory for apipod commands (scan/build/start/deploy).",
154
277
  )
278
+ parser.add_argument(
279
+ "-C",
280
+ "--cwd",
281
+ dest="project_dir",
282
+ metavar="DIR",
283
+ help=argparse.SUPPRESS,
284
+ )
155
285
 
156
286
  subparsers = parser.add_subparsers(dest="command")
157
287
 
@@ -201,6 +331,59 @@ Examples:
201
331
  )
202
332
  jobs_parser.add_argument("--limit", type=int, default=25)
203
333
 
334
+ interrupts_parser = subparsers.add_parser(
335
+ "interrupts", help="Your HIT approval inbox: list, inspect, or answer agent action requests (requires login)"
336
+ )
337
+ interrupts_parser.add_argument("interrupt_id", nargs="?", default=None, help="Interrupt id for detail or resolve")
338
+ interrupts_parser.add_argument(
339
+ "--resolve", default=None, choices=("approve", "edit", "reject", "respond"),
340
+ help="Answer the given interrupt with this decision",
341
+ )
342
+ interrupts_parser.add_argument("--message", default=None, help="Reject feedback or respond answer text")
343
+ interrupts_parser.add_argument("--expand", default=None, help="Comma-separated: chat,chat_item,job")
344
+ interrupts_parser.add_argument("--status", default=None, help="Filter by status (default: pending)")
345
+ interrupts_parser.add_argument(
346
+ "--no-continue", action="store_true",
347
+ help="Record the decision without enqueuing the agent continue job",
348
+ )
349
+ interrupts_parser.add_argument("--limit", type=int, default=25)
350
+
351
+ workflows_parser = subparsers.add_parser(
352
+ "workflows", help="Manage and run your workflows (requires login)"
353
+ )
354
+ workflows_sub = workflows_parser.add_subparsers(dest="workflows_command")
355
+
356
+ wf_list = workflows_sub.add_parser("list", help="List your workflows plus public ones")
357
+ wf_list.add_argument("--q", default=None, help="Fuzzy search over title, goal, slug, and node text")
358
+ wf_list.add_argument("--limit", type=int, default=25)
359
+
360
+ wf_get = workflows_sub.add_parser("get", help="Show one workflow including its document")
361
+ wf_get.add_argument("workflow_id", help="Workflow id (wf_...) or slug")
362
+
363
+ wf_push = workflows_sub.add_parser("push", help="Upsert a workflow from a JSON document file")
364
+ wf_push.add_argument("file", help="Path to the workflow document JSON")
365
+ wf_push.add_argument("--slug", default=None, help="Stable per-owner handle")
366
+ wf_push.add_argument("--message", default=None, help="Revision message (changelog)")
367
+
368
+ wf_publish = workflows_sub.add_parser("publish", help="Publish a workflow (pins a revision)")
369
+ wf_publish.add_argument("workflow_id", help="Workflow id (wf_...) or slug")
370
+ wf_publish.add_argument("--revision-id", default=None, help="rv_ id to pin; default latest valid")
371
+
372
+ wf_runs = workflows_sub.add_parser("runs", help="List workflow runs, or inspect one (wr_ id)")
373
+ wf_runs.add_argument(
374
+ "workflow_id", nargs="?", default=None,
375
+ help="Workflow id to filter by, or a run id (wr_...) for a detailed view",
376
+ )
377
+ wf_runs.add_argument("--limit", type=int, default=25)
378
+
379
+ wf_run = workflows_sub.add_parser("run", help="Run a workflow via the inference gateway (requires socaity)")
380
+ wf_run.add_argument("workflow_id", help="Workflow id (wf_...) or slug")
381
+ wf_run.add_argument(
382
+ "--input", action="append", default=None, metavar="KEY=VALUE",
383
+ help="Workflow input (repeatable). Values parse as JSON when possible.",
384
+ )
385
+ wf_run.add_argument("--timeout", type=float, default=None, help="Give up waiting after this many seconds")
386
+
204
387
  projects_parser = subparsers.add_parser("projects", help="List your projects or inspect one (requires login)")
205
388
  projects_parser.add_argument("project_id", nargs="?", default=None, help="Project id for a detailed view")
206
389
  projects_parser.add_argument("--expand", default=None, help="Comma-separated: jobs (nested: jobs.data, ...)")
@@ -224,7 +407,7 @@ Examples:
224
407
  "Examples:\n"
225
408
  " socaity deploy\n"
226
409
  " socaity deploy serverless-runpod\n"
227
- " socaity -C simple_test_service deploy serverless-runpod --yes\n"
410
+ " socaity --project-dir simple_test_service deploy serverless-runpod --yes\n"
228
411
  " socaity deploy --skip-build\n"
229
412
  " socaity deploy --resume DEPLOYMENT_ID"
230
413
  ),
@@ -318,6 +501,22 @@ Examples:
318
501
  _run_jobs(args.job_id, args.expand, args.status, args.visibility, args.limit, q=args.q)
319
502
  return
320
503
 
504
+ if args.command == "interrupts":
505
+ require_credentials()
506
+ _run_interrupts(
507
+ args.interrupt_id, args.resolve, args.message,
508
+ args.expand, args.status, args.limit, no_continue=args.no_continue,
509
+ )
510
+ return
511
+
512
+ if args.command == "workflows":
513
+ if not args.workflows_command:
514
+ workflows_parser.print_help()
515
+ return
516
+ require_credentials()
517
+ _run_workflows(args)
518
+ return
519
+
321
520
  if args.command == "projects":
322
521
  require_credentials()
323
522
  _run_projects(args.project_id, args.expand, args.limit)
@@ -1,15 +1,84 @@
1
1
  """AI catalog reads (`/v1/catalog`)."""
2
+ import re
2
3
  from typing import Dict, List, Optional, Union
3
4
 
4
5
  from socaity_schemas.platform import AIModel, AIService, Job, ServiceCategory
5
6
 
6
7
  SERVICE_LIST_FIELDS = ["id", "name", "display_name", "short_desc", "categories", "is_official", "n_usages"]
8
+ SERVICE_DETAIL_EXPAND = ["deployments.contract", "endpoints", "models"]
7
9
  MODEL_LIST_FIELDS = ["id", "name", "display_name", "family", "release_date", "hugging_face_url"]
8
10
 
9
11
 
10
12
  class CatalogClient:
11
13
  """Mixin: catalog list/get/search. Requires ``BackendHttp`` methods on ``self``."""
12
14
 
15
+ SERVICE_DETAIL_EXPAND = SERVICE_DETAIL_EXPAND
16
+
17
+ def search_services(
18
+ self,
19
+ query: Optional[str] = None,
20
+ category: Optional[str] = None,
21
+ filters: Optional[List[str]] = None,
22
+ expand: Optional[List[str]] = None,
23
+ fields: Optional[List[str]] = None,
24
+ sort: Optional[str] = None,
25
+ limit: int = 20,
26
+ offset: int = 0,
27
+ mine: bool = False,
28
+ ) -> List[AIService]:
29
+ """Find AI services in the socaity catalog.
30
+
31
+ Start here for any generation task. Search by capability ("text to image",
32
+ "voice cloning"). Results are slim catalog rows; call ``get_service`` with
33
+ a hit ``id`` for endpoints and the call signature under
34
+ ``deployments.contract``.
35
+
36
+ Args:
37
+ query: Free-text search. Omit to browse.
38
+ category: Category id (not display name).
39
+ filters: Repeatable ``field:operator:value`` strings.
40
+ expand: Relations to embed (``deployments``, ``models``, ``pricing``).
41
+ fields: Sparse fieldset, e.g. ``["id", "name"]``.
42
+ sort: ``field:asc`` or ``field:desc``.
43
+ limit: Page size, 1 to 100.
44
+ offset: Skip this many rows; increase by ``limit`` for the next page.
45
+ mine: Only services owned by the caller.
46
+
47
+ Returns:
48
+ Service rows, or an empty list when nothing matches.
49
+ """
50
+ service_filters = list(filters or [])
51
+ if category:
52
+ service_filters.append(f"categories:contains:{category}")
53
+ rows = self.query_services(
54
+ q=query,
55
+ filters=service_filters or None,
56
+ expand=expand,
57
+ fields=fields,
58
+ sort=sort,
59
+ limit=limit,
60
+ offset=offset,
61
+ mine=mine,
62
+ )
63
+ # Stale Typesense (watermark after empty first sync, or a later
64
+ # write-through of one service) can return [] or the wrong hit while
65
+ # GET /services still lists the row. Browse + local token match fills
66
+ # the gap without waiting for an index rebuild.
67
+ if query and not mine:
68
+ matched = [row for row in rows if _service_matches_query(row, query)]
69
+ if matched:
70
+ return matched[:limit]
71
+ browsed = self.query_services(
72
+ filters=service_filters or None,
73
+ expand=expand,
74
+ fields=fields,
75
+ sort=sort,
76
+ limit=max(limit, 100),
77
+ offset=0,
78
+ )
79
+ return [row for row in browsed if _service_matches_query(row, query)][:limit]
80
+ return rows
81
+
13
82
  def query_services(
14
83
  self,
15
84
  q: Optional[str] = None,
@@ -21,6 +90,7 @@ class CatalogClient:
21
90
  offset: int = 0,
22
91
  known_ids: Optional[List[str]] = None,
23
92
  known_fieldset: Optional[str] = None,
93
+ mine: bool = False,
24
94
  ) -> List[AIService]:
25
95
  rows = self._unwrap_list(self._get("v1/catalog/services", params={
26
96
  "q": q,
@@ -32,6 +102,7 @@ class CatalogClient:
32
102
  "offset": offset,
33
103
  "known_ids": known_ids or [],
34
104
  "known_fieldset": known_fieldset,
105
+ "mine": mine,
35
106
  }))
36
107
  return [AIService(**row) for row in rows]
37
108
 
@@ -54,14 +125,29 @@ class CatalogClient:
54
125
 
55
126
  def get_service(
56
127
  self,
57
- id_or_name: str,
128
+ identifier: str,
58
129
  expand: Optional[List[str]] = None,
59
130
  fields: Optional[List[str]] = None,
60
131
  filters: Optional[List[str]] = None,
61
132
  ) -> Optional[AIService]:
62
- row = self._get(f"v1/catalog/services/{id_or_name}", params={
133
+ """Fetch one catalog service by id, name, or ``owner/service``.
134
+
135
+ Default expand embeds ``deployments.contract``, platform ``endpoints`` and
136
+ ``models``. The call signature for ``run_service`` lives under
137
+ ``deployments[].contract.endpoints[].parameters``.
138
+
139
+ Args:
140
+ identifier: Service id or slug from ``search_services``.
141
+ expand: Override the default expand list.
142
+ fields: Sparse fieldset.
143
+ filters: Post-fetch filters.
144
+
145
+ Returns:
146
+ The service record, or ``None`` when not found or not visible.
147
+ """
148
+ row = self._get(f"v1/catalog/services/{identifier}", params={
63
149
  "filter": filters or [],
64
- "expand": expand if expand is not None else ["deployments", "endpoints", "models"],
150
+ "expand": expand if expand is not None else SERVICE_DETAIL_EXPAND,
65
151
  "fields": fields or [],
66
152
  })
67
153
  return AIService(**row) if row else None
@@ -144,4 +230,35 @@ class CatalogClient:
144
230
  return self.query_models(q=query, filters=filters, limit=limit)
145
231
  if collection == "jobs":
146
232
  return self.query_jobs(q=query, filters=filters, visibility=visibility, limit=limit)
147
- return self.query_services(q=query, filters=filters, limit=limit)
233
+ return self.search_services(query=query, filters=filters, limit=limit)
234
+
235
+
236
+ def _service_matches_query(service: AIService, text: str) -> bool:
237
+ tokens = _query_tokens(text)
238
+ if not tokens:
239
+ return True
240
+ hay = _fold(" ".join(filter(None, (
241
+ getattr(service, "name", None),
242
+ getattr(service, "display_name", None),
243
+ getattr(service, "short_desc", None),
244
+ getattr(service, "description", None),
245
+ ))))
246
+ return all(token in hay for token in tokens)
247
+
248
+
249
+ _QUERY_STOP = frozenset({
250
+ "a", "an", "the", "by", "for", "of", "and", "or", "to", "in", "on",
251
+ "image", "images", "video", "audio", "text", "generation", "generate",
252
+ "model", "models", "service", "services", "ai", "good", "best",
253
+ })
254
+
255
+
256
+ def _query_tokens(text: str) -> list[str]:
257
+ """Distinctive tokens. Drop filler so 'Labs image generation' still hits flux."""
258
+ raw = _fold(text).split()
259
+ kept = [token for token in raw if token not in _QUERY_STOP and len(token) > 2]
260
+ return kept or raw
261
+
262
+
263
+ def _fold(value: str) -> str:
264
+ return re.sub(r"[^a-z0-9]+", " ", (value or "").lower()).strip()
@@ -0,0 +1,81 @@
1
+ """Conversations facade (`/v1/conversations`)."""
2
+
3
+ from typing import List, Optional
4
+
5
+ from socaity_schemas.platform import Chat, ChatItem
6
+
7
+
8
+ class ConversationsClient:
9
+ """Mixin: conversation metadata and items. Requires ``BackendHttp`` methods on ``self``."""
10
+
11
+ def list_conversations(
12
+ self,
13
+ q: Optional[str] = None,
14
+ filters: Optional[List[str]] = None,
15
+ expand: Optional[List[str]] = None,
16
+ fields: Optional[List[str]] = None,
17
+ sort: Optional[str] = None,
18
+ limit: int = 100,
19
+ offset: int = 0,
20
+ ) -> List[Chat]:
21
+ """Conversations owned by the caller, newest activity first (fuzzy ``q`` via Typesense)."""
22
+ rows = self._unwrap_list(self._get("v1/conversations", params={
23
+ "q": q,
24
+ "filter": filters or [],
25
+ "expand": expand or [],
26
+ "fields": fields or [],
27
+ "sort": sort,
28
+ "limit": limit,
29
+ "offset": offset,
30
+ }))
31
+ return [Chat(**row) for row in rows]
32
+
33
+ def get_conversation(
34
+ self,
35
+ conversation_id: str,
36
+ expand: Optional[List[str]] = None,
37
+ fields: Optional[List[str]] = None,
38
+ ) -> Optional[Chat]:
39
+ """Fetch one conversation by id; relations omitted unless expanded."""
40
+ row = self._get(f"v1/conversations/{conversation_id}", params={
41
+ "expand": expand or [],
42
+ "fields": fields or [],
43
+ })
44
+ return Chat(**row) if row else None
45
+
46
+ def list_conversation_items(
47
+ self,
48
+ conversation_id: str,
49
+ branch: str = "active",
50
+ after: Optional[str] = None,
51
+ order: str = "asc",
52
+ filters: Optional[List[str]] = None,
53
+ limit: int = 100,
54
+ ) -> List[ChatItem]:
55
+ """Typed items of one conversation. ``branch=active`` walks the active chain; ``all`` is the full tree."""
56
+ rows = self._get(f"v1/conversations/{conversation_id}/items", params={
57
+ "branch": branch,
58
+ "after": after,
59
+ "order": order,
60
+ "filter": filters or [],
61
+ "limit": limit,
62
+ }) or []
63
+ return [ChatItem(**row) for row in rows]
64
+
65
+ def update_conversation(
66
+ self,
67
+ conversation_id: str,
68
+ title: Optional[str] = None,
69
+ status: Optional[str] = None,
70
+ ) -> Optional[Chat]:
71
+ """Patch title and/or status on a conversation the caller owns."""
72
+ row = self._request(
73
+ "PATCH",
74
+ f"v1/conversations/{conversation_id}",
75
+ params={"title": title, "status": status},
76
+ )
77
+ return Chat(**row) if row else None
78
+
79
+ def delete_conversation(self, conversation_id: str) -> bool:
80
+ """Soft-delete a conversation and clear its linked job data."""
81
+ return bool(self._request("DELETE", f"v1/conversations/{conversation_id}"))