openaivec 1.0.9__tar.gz → 1.0.11__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 (92) hide show
  1. openaivec-1.0.11/.github/workflows/test-pr.yml +71 -0
  2. {openaivec-1.0.9 → openaivec-1.0.11}/.github/workflows/test.yml +1 -0
  3. {openaivec-1.0.9 → openaivec-1.0.11}/PKG-INFO +9 -8
  4. {openaivec-1.0.9 → openaivec-1.0.11}/README.md +8 -7
  5. {openaivec-1.0.9 → openaivec-1.0.11}/src/openaivec/_cache/optimize.py +20 -5
  6. {openaivec-1.0.9 → openaivec-1.0.11}/src/openaivec/_provider.py +57 -6
  7. {openaivec-1.0.9 → openaivec-1.0.11}/tests/_cache/test_optimize.py +78 -6
  8. {openaivec-1.0.9 → openaivec-1.0.11}/tests/_cache/test_proxy_suggester.py +2 -1
  9. {openaivec-1.0.9 → openaivec-1.0.11}/tests/test_provider.py +103 -15
  10. {openaivec-1.0.9 → openaivec-1.0.11}/uv.lock +237 -237
  11. {openaivec-1.0.9 → openaivec-1.0.11}/.env.example +0 -0
  12. {openaivec-1.0.9 → openaivec-1.0.11}/.github/copilot-instructions.md +0 -0
  13. {openaivec-1.0.9 → openaivec-1.0.11}/.github/dependabot.yml +0 -0
  14. {openaivec-1.0.9 → openaivec-1.0.11}/.github/workflows/docs.yml +0 -0
  15. {openaivec-1.0.9 → openaivec-1.0.11}/.github/workflows/publish.yml +0 -0
  16. {openaivec-1.0.9 → openaivec-1.0.11}/.gitignore +0 -0
  17. {openaivec-1.0.9 → openaivec-1.0.11}/AGENTS.md +0 -0
  18. {openaivec-1.0.9 → openaivec-1.0.11}/CODE_OF_CONDUCT.md +0 -0
  19. {openaivec-1.0.9 → openaivec-1.0.11}/LICENSE +0 -0
  20. {openaivec-1.0.9 → openaivec-1.0.11}/SECURITY.md +0 -0
  21. {openaivec-1.0.9 → openaivec-1.0.11}/SUPPORT.md +0 -0
  22. {openaivec-1.0.9 → openaivec-1.0.11}/docs/api/main.md +0 -0
  23. {openaivec-1.0.9 → openaivec-1.0.11}/docs/api/pandas_ext.md +0 -0
  24. {openaivec-1.0.9 → openaivec-1.0.11}/docs/api/spark.md +0 -0
  25. {openaivec-1.0.9 → openaivec-1.0.11}/docs/api/task.md +0 -0
  26. {openaivec-1.0.9 → openaivec-1.0.11}/docs/api/tasks/customer_support/customer_sentiment.md +0 -0
  27. {openaivec-1.0.9 → openaivec-1.0.11}/docs/api/tasks/customer_support/inquiry_classification.md +0 -0
  28. {openaivec-1.0.9 → openaivec-1.0.11}/docs/api/tasks/customer_support/inquiry_summary.md +0 -0
  29. {openaivec-1.0.9 → openaivec-1.0.11}/docs/api/tasks/customer_support/intent_analysis.md +0 -0
  30. {openaivec-1.0.9 → openaivec-1.0.11}/docs/api/tasks/customer_support/response_suggestion.md +0 -0
  31. {openaivec-1.0.9 → openaivec-1.0.11}/docs/api/tasks/customer_support/urgency_analysis.md +0 -0
  32. {openaivec-1.0.9 → openaivec-1.0.11}/docs/api/tasks/nlp/dependency_parsing.md +0 -0
  33. {openaivec-1.0.9 → openaivec-1.0.11}/docs/api/tasks/nlp/keyword_extraction.md +0 -0
  34. {openaivec-1.0.9 → openaivec-1.0.11}/docs/api/tasks/nlp/morphological_analysis.md +0 -0
  35. {openaivec-1.0.9 → openaivec-1.0.11}/docs/api/tasks/nlp/named_entity_recognition.md +0 -0
  36. {openaivec-1.0.9 → openaivec-1.0.11}/docs/api/tasks/nlp/sentiment_analysis.md +0 -0
  37. {openaivec-1.0.9 → openaivec-1.0.11}/docs/api/tasks/nlp/translation.md +0 -0
  38. {openaivec-1.0.9 → openaivec-1.0.11}/docs/contributor-guide.md +0 -0
  39. {openaivec-1.0.9 → openaivec-1.0.11}/docs/index.md +0 -0
  40. {openaivec-1.0.9 → openaivec-1.0.11}/docs/overrides/main.html +0 -0
  41. {openaivec-1.0.9 → openaivec-1.0.11}/docs/robots.txt +0 -0
  42. {openaivec-1.0.9 → openaivec-1.0.11}/mkdocs.yml +0 -0
  43. {openaivec-1.0.9 → openaivec-1.0.11}/pyproject.toml +0 -0
  44. {openaivec-1.0.9 → openaivec-1.0.11}/pytest.ini +0 -0
  45. {openaivec-1.0.9 → openaivec-1.0.11}/src/openaivec/__init__.py +0 -0
  46. {openaivec-1.0.9 → openaivec-1.0.11}/src/openaivec/_cache/__init__.py +0 -0
  47. {openaivec-1.0.9 → openaivec-1.0.11}/src/openaivec/_cache/proxy.py +0 -0
  48. {openaivec-1.0.9 → openaivec-1.0.11}/src/openaivec/_di.py +0 -0
  49. {openaivec-1.0.9 → openaivec-1.0.11}/src/openaivec/_embeddings.py +0 -0
  50. {openaivec-1.0.9 → openaivec-1.0.11}/src/openaivec/_log.py +0 -0
  51. {openaivec-1.0.9 → openaivec-1.0.11}/src/openaivec/_model.py +0 -0
  52. {openaivec-1.0.9 → openaivec-1.0.11}/src/openaivec/_prompt.py +0 -0
  53. {openaivec-1.0.9 → openaivec-1.0.11}/src/openaivec/_responses.py +0 -0
  54. {openaivec-1.0.9 → openaivec-1.0.11}/src/openaivec/_schema/__init__.py +0 -0
  55. {openaivec-1.0.9 → openaivec-1.0.11}/src/openaivec/_schema/infer.py +0 -0
  56. {openaivec-1.0.9 → openaivec-1.0.11}/src/openaivec/_schema/spec.py +0 -0
  57. {openaivec-1.0.9 → openaivec-1.0.11}/src/openaivec/_serialize.py +0 -0
  58. {openaivec-1.0.9 → openaivec-1.0.11}/src/openaivec/_util.py +0 -0
  59. {openaivec-1.0.9 → openaivec-1.0.11}/src/openaivec/pandas_ext.py +0 -0
  60. {openaivec-1.0.9 → openaivec-1.0.11}/src/openaivec/spark.py +0 -0
  61. {openaivec-1.0.9 → openaivec-1.0.11}/src/openaivec/task/__init__.py +0 -0
  62. {openaivec-1.0.9 → openaivec-1.0.11}/src/openaivec/task/customer_support/__init__.py +0 -0
  63. {openaivec-1.0.9 → openaivec-1.0.11}/src/openaivec/task/customer_support/customer_sentiment.py +0 -0
  64. {openaivec-1.0.9 → openaivec-1.0.11}/src/openaivec/task/customer_support/inquiry_classification.py +0 -0
  65. {openaivec-1.0.9 → openaivec-1.0.11}/src/openaivec/task/customer_support/inquiry_summary.py +0 -0
  66. {openaivec-1.0.9 → openaivec-1.0.11}/src/openaivec/task/customer_support/intent_analysis.py +0 -0
  67. {openaivec-1.0.9 → openaivec-1.0.11}/src/openaivec/task/customer_support/response_suggestion.py +0 -0
  68. {openaivec-1.0.9 → openaivec-1.0.11}/src/openaivec/task/customer_support/urgency_analysis.py +0 -0
  69. {openaivec-1.0.9 → openaivec-1.0.11}/src/openaivec/task/nlp/__init__.py +0 -0
  70. {openaivec-1.0.9 → openaivec-1.0.11}/src/openaivec/task/nlp/dependency_parsing.py +0 -0
  71. {openaivec-1.0.9 → openaivec-1.0.11}/src/openaivec/task/nlp/keyword_extraction.py +0 -0
  72. {openaivec-1.0.9 → openaivec-1.0.11}/src/openaivec/task/nlp/morphological_analysis.py +0 -0
  73. {openaivec-1.0.9 → openaivec-1.0.11}/src/openaivec/task/nlp/named_entity_recognition.py +0 -0
  74. {openaivec-1.0.9 → openaivec-1.0.11}/src/openaivec/task/nlp/sentiment_analysis.py +0 -0
  75. {openaivec-1.0.9 → openaivec-1.0.11}/src/openaivec/task/nlp/translation.py +0 -0
  76. {openaivec-1.0.9 → openaivec-1.0.11}/src/openaivec/task/table/__init__.py +0 -0
  77. {openaivec-1.0.9 → openaivec-1.0.11}/src/openaivec/task/table/fillna.py +0 -0
  78. {openaivec-1.0.9 → openaivec-1.0.11}/tests/__init__.py +0 -0
  79. {openaivec-1.0.9 → openaivec-1.0.11}/tests/_cache/test_proxy.py +0 -0
  80. {openaivec-1.0.9 → openaivec-1.0.11}/tests/_schema/test_infer.py +0 -0
  81. {openaivec-1.0.9 → openaivec-1.0.11}/tests/_schema/test_spec.py +0 -0
  82. {openaivec-1.0.9 → openaivec-1.0.11}/tests/conftest.py +0 -0
  83. {openaivec-1.0.9 → openaivec-1.0.11}/tests/test_di.py +0 -0
  84. {openaivec-1.0.9 → openaivec-1.0.11}/tests/test_embeddings.py +0 -0
  85. {openaivec-1.0.9 → openaivec-1.0.11}/tests/test_pandas_ext.py +0 -0
  86. {openaivec-1.0.9 → openaivec-1.0.11}/tests/test_prompt.py +0 -0
  87. {openaivec-1.0.9 → openaivec-1.0.11}/tests/test_responses.py +0 -0
  88. {openaivec-1.0.9 → openaivec-1.0.11}/tests/test_serialize.py +0 -0
  89. {openaivec-1.0.9 → openaivec-1.0.11}/tests/test_serialize_pydantic_v2_compliance.py +0 -0
  90. {openaivec-1.0.9 → openaivec-1.0.11}/tests/test_spark.py +0 -0
  91. {openaivec-1.0.9 → openaivec-1.0.11}/tests/test_task.py +0 -0
  92. {openaivec-1.0.9 → openaivec-1.0.11}/tests/test_util.py +0 -0
@@ -0,0 +1,71 @@
1
+ name: ci-integration
2
+
3
+ on:
4
+ issue_comment:
5
+ types: [created]
6
+
7
+ permissions:
8
+ contents: read
9
+ issues: write
10
+ pull-requests: read
11
+
12
+ jobs:
13
+ test:
14
+ if: >
15
+ github.event.issue.pull_request &&
16
+ contains(github.event.comment.body, '/run-integration') &&
17
+ (github.event.comment.author_association == 'MEMBER' ||
18
+ github.event.comment.author_association == 'OWNER' ||
19
+ github.event.comment.author_association == 'COLLABORATOR')
20
+
21
+ runs-on: ubuntu-latest
22
+ environment: integration
23
+ env:
24
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
25
+
26
+ steps:
27
+ - name: Comment workflow run link
28
+ shell: bash
29
+ run: |
30
+ set -euo pipefail
31
+ COMMENTS_URL="${{ github.event.issue.comments_url }}"
32
+ RUN_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
33
+ export RUN_URL
34
+ PAYLOAD="$(python -c 'import json,os; print(json.dumps({\"body\": f\"Integration workflow started: {os.environ[\\\"RUN_URL\\\"]}\"}))')"
35
+ curl -sS -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
36
+ -H "Accept: application/vnd.github+json" \
37
+ -d "$PAYLOAD" \
38
+ "$COMMENTS_URL"
39
+
40
+ - name: Fetch PR head SHA
41
+ id: pr
42
+ shell: bash
43
+ run: |
44
+ set -euo pipefail
45
+ PR_API_URL="${{ github.event.issue.pull_request.url }}"
46
+ JSON="$(curl -sS -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
47
+ -H "Accept: application/vnd.github+json" "$PR_API_URL")"
48
+ echo "sha=$(python -c 'import json,sys; print(json.load(sys.stdin)[\"head\"][\"sha\"])' <<<\"$JSON\")" >> "$GITHUB_OUTPUT"
49
+
50
+ - name: Checkout PR head commit
51
+ uses: actions/checkout@v4
52
+ with:
53
+ ref: ${{ steps.pr.outputs.sha }}
54
+
55
+ - name: Install uv
56
+ uses: astral-sh/setup-uv@v7
57
+
58
+ - name: Set up Python
59
+ run: uv python install 3.10
60
+
61
+ - name: Install dependencies via uv
62
+ run: uv sync --all-extras --dev
63
+
64
+ - name: Lint with ruff
65
+ run: uv run ruff check .
66
+
67
+ - name: Type check with pyright
68
+ run: uv run pyright src/openaivec || echo "Type check completed with issues - see above"
69
+
70
+ - name: Run tests
71
+ run: uv run pytest
@@ -11,6 +11,7 @@ permissions:
11
11
  jobs:
12
12
  test:
13
13
  runs-on: ubuntu-latest
14
+ environment: integration
14
15
  env:
15
16
  OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
16
17
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openaivec
3
- Version: 1.0.9
3
+ Version: 1.0.11
4
4
  Summary: Generative mutation for tabular calculation
5
5
  Project-URL: Homepage, https://microsoft.github.io/openaivec/
6
6
  Project-URL: Repository, https://github.com/microsoft/openaivec
@@ -60,9 +60,10 @@ sentiment = reviews.ai.responses(
60
60
  reasoning={"effort": "none"}, # Mirrors OpenAI SDK for reasoning models
61
61
  )
62
62
  print(sentiment.tolist())
63
+ # Output: ['Positive sentiment', 'Negative sentiment']
63
64
  ```
64
65
 
65
- **Try it live:** https://microsoft.github.io/openaivec/examples/pandas/
66
+ **Pandas tutorial (GitHub Pages):** https://microsoft.github.io/openaivec/examples/pandas/
66
67
 
67
68
  ## Benchmarks
68
69
 
@@ -81,6 +82,7 @@ Batching alone removes most HTTP overhead, and letting batching overlap with con
81
82
  ## Contents
82
83
 
83
84
  - [Why openaivec?](#why-openaivec)
85
+ - [Overview](#overview)
84
86
  - [Core Workflows](#core-workflows)
85
87
  - [Using with Apache Spark UDFs](#using-with-apache-spark-udfs)
86
88
  - [Building Prompts](#building-prompts)
@@ -92,14 +94,13 @@ Batching alone removes most HTTP overhead, and letting batching overlap with con
92
94
  ## Why openaivec?
93
95
 
94
96
  - Drop-in `.ai` and `.aio` accessors keep pandas analysts in familiar tooling.
95
- - OpenAI batch-optimized: `BatchingMapProxy`/`AsyncBatchingMapProxy` coalesce requests, dedupe prompts, and keep column order stable.
96
- - Smart batching (`BatchingMapProxy`/`AsyncBatchingMapProxy`) dedupes prompts, preserves order, and releases waiters on failure.
97
+ - OpenAI batch-optimized: `BatchingMapProxy`/`AsyncBatchingMapProxy` coalesce requests, dedupe prompts, preserve order, and release waiters on failure.
97
98
  - Reasoning support mirrors the OpenAI SDK; structured outputs accept Pydantic `response_format`.
98
99
  - Built-in caches and retries remove boilerplate; helpers reuse caches across pandas, Spark, and async flows.
99
100
  - Spark UDFs and Microsoft Fabric guides move notebooks into production-scale ETL.
100
101
  - Prompt tooling (`FewShotPromptBuilder`, `improve`) and the task library ship curated prompts with validated outputs.
101
102
 
102
- # Overview
103
+ ## Overview
103
104
 
104
105
  Vectorized OpenAI batch processing so you handle many inputs per call instead of one-by-one. Batching proxies dedupe inputs, enforce ordered outputs, and unblock waiters even on upstream errors. Cache helpers (`responses_with_cache`, Spark UDF builders) plug into the same layer so expensive prompts are reused across pandas, Spark, and async flows. Reasoning models honor SDK semantics. Requires Python 3.10+.
105
106
 
@@ -185,7 +186,7 @@ result = df.assign(
185
186
 
186
187
  ### Using with reasoning models
187
188
 
188
- Reasoning models (o1-preview, o1-mini, o3-mini, etc.) work without special flags. `reasoning` mirrors the OpenAI SDK.
189
+ Reasoning models (o1-preview, o1-mini, o3-mini, etc.) follow OpenAI SDK semantics. Pass `reasoning` when you want to override model defaults.
189
190
 
190
191
  ```python
191
192
  pandas_ext.set_responses_model("o1-mini") # Set your reasoning model
@@ -193,7 +194,7 @@ pandas_ext.set_responses_model("o1-mini") # Set your reasoning model
193
194
  result = df.assign(
194
195
  analysis=lambda df: df.text.ai.responses(
195
196
  "Analyze this text step by step",
196
- reasoning={"effort": "none"} # Optional: mirrors the OpenAI SDK argument
197
+ reasoning={"effort": "none"}, # Optional: mirrors the OpenAI SDK argument
197
198
  )
198
199
  )
199
200
  ```
@@ -253,7 +254,7 @@ df = pd.DataFrame({"text": [
253
254
  async def process_data():
254
255
  return await df["text"].aio.responses(
255
256
  "Analyze sentiment and classify as positive/negative/neutral",
256
- reasoning={"effort": "none"}, # Required for gpt-5.1
257
+ reasoning={"effort": "none"}, # Recommended for reasoning models
257
258
  max_concurrency=12 # Allow up to 12 concurrent requests
258
259
  )
259
260
 
@@ -34,9 +34,10 @@ sentiment = reviews.ai.responses(
34
34
  reasoning={"effort": "none"}, # Mirrors OpenAI SDK for reasoning models
35
35
  )
36
36
  print(sentiment.tolist())
37
+ # Output: ['Positive sentiment', 'Negative sentiment']
37
38
  ```
38
39
 
39
- **Try it live:** https://microsoft.github.io/openaivec/examples/pandas/
40
+ **Pandas tutorial (GitHub Pages):** https://microsoft.github.io/openaivec/examples/pandas/
40
41
 
41
42
  ## Benchmarks
42
43
 
@@ -55,6 +56,7 @@ Batching alone removes most HTTP overhead, and letting batching overlap with con
55
56
  ## Contents
56
57
 
57
58
  - [Why openaivec?](#why-openaivec)
59
+ - [Overview](#overview)
58
60
  - [Core Workflows](#core-workflows)
59
61
  - [Using with Apache Spark UDFs](#using-with-apache-spark-udfs)
60
62
  - [Building Prompts](#building-prompts)
@@ -66,14 +68,13 @@ Batching alone removes most HTTP overhead, and letting batching overlap with con
66
68
  ## Why openaivec?
67
69
 
68
70
  - Drop-in `.ai` and `.aio` accessors keep pandas analysts in familiar tooling.
69
- - OpenAI batch-optimized: `BatchingMapProxy`/`AsyncBatchingMapProxy` coalesce requests, dedupe prompts, and keep column order stable.
70
- - Smart batching (`BatchingMapProxy`/`AsyncBatchingMapProxy`) dedupes prompts, preserves order, and releases waiters on failure.
71
+ - OpenAI batch-optimized: `BatchingMapProxy`/`AsyncBatchingMapProxy` coalesce requests, dedupe prompts, preserve order, and release waiters on failure.
71
72
  - Reasoning support mirrors the OpenAI SDK; structured outputs accept Pydantic `response_format`.
72
73
  - Built-in caches and retries remove boilerplate; helpers reuse caches across pandas, Spark, and async flows.
73
74
  - Spark UDFs and Microsoft Fabric guides move notebooks into production-scale ETL.
74
75
  - Prompt tooling (`FewShotPromptBuilder`, `improve`) and the task library ship curated prompts with validated outputs.
75
76
 
76
- # Overview
77
+ ## Overview
77
78
 
78
79
  Vectorized OpenAI batch processing so you handle many inputs per call instead of one-by-one. Batching proxies dedupe inputs, enforce ordered outputs, and unblock waiters even on upstream errors. Cache helpers (`responses_with_cache`, Spark UDF builders) plug into the same layer so expensive prompts are reused across pandas, Spark, and async flows. Reasoning models honor SDK semantics. Requires Python 3.10+.
79
80
 
@@ -159,7 +160,7 @@ result = df.assign(
159
160
 
160
161
  ### Using with reasoning models
161
162
 
162
- Reasoning models (o1-preview, o1-mini, o3-mini, etc.) work without special flags. `reasoning` mirrors the OpenAI SDK.
163
+ Reasoning models (o1-preview, o1-mini, o3-mini, etc.) follow OpenAI SDK semantics. Pass `reasoning` when you want to override model defaults.
163
164
 
164
165
  ```python
165
166
  pandas_ext.set_responses_model("o1-mini") # Set your reasoning model
@@ -167,7 +168,7 @@ pandas_ext.set_responses_model("o1-mini") # Set your reasoning model
167
168
  result = df.assign(
168
169
  analysis=lambda df: df.text.ai.responses(
169
170
  "Analyze this text step by step",
170
- reasoning={"effort": "none"} # Optional: mirrors the OpenAI SDK argument
171
+ reasoning={"effort": "none"}, # Optional: mirrors the OpenAI SDK argument
171
172
  )
172
173
  )
173
174
  ```
@@ -227,7 +228,7 @@ df = pd.DataFrame({"text": [
227
228
  async def process_data():
228
229
  return await df["text"].aio.responses(
229
230
  "Analyze sentiment and classify as positive/negative/neutral",
230
- reasoning={"effort": "none"}, # Required for gpt-5.1
231
+ reasoning={"effort": "none"}, # Recommended for reasoning models
231
232
  max_concurrency=12 # Allow up to 12 concurrent requests
232
233
  )
233
234
 
@@ -21,7 +21,10 @@ class BatchSizeSuggester:
21
21
  min_batch_size: int = 10
22
22
  min_duration: float = 30.0
23
23
  max_duration: float = 60.0
24
- step_ratio: float = 0.2
24
+ step_ratio_up: float = 0.1
25
+ step_ratio_down: float = 0.2
26
+ max_step: int | None = None
27
+ min_step: int = 1
25
28
  sample_size: int = 4
26
29
  _history: list[PerformanceMetric] = field(default_factory=list)
27
30
  _lock: threading.RLock = field(default_factory=threading.RLock, repr=False)
@@ -34,8 +37,14 @@ class BatchSizeSuggester:
34
37
  raise ValueError("current_batch_size must be >= min_batch_size")
35
38
  if self.sample_size <= 0:
36
39
  raise ValueError("sample_size must be > 0")
37
- if self.step_ratio <= 0:
38
- raise ValueError("step_ratio must be > 0")
40
+ if self.step_ratio_up <= 0:
41
+ raise ValueError("step_ratio_up must be > 0")
42
+ if self.step_ratio_down <= 0:
43
+ raise ValueError("step_ratio_down must be > 0")
44
+ if self.max_step is not None and self.max_step <= 0:
45
+ raise ValueError("max_step must be > 0")
46
+ if self.min_step <= 0:
47
+ raise ValueError("min_step must be > 0")
39
48
  if self.min_duration <= 0 or self.max_duration <= 0:
40
49
  raise ValueError("min_duration and max_duration must be > 0")
41
50
  if self.min_duration >= self.max_duration:
@@ -94,9 +103,15 @@ class BatchSizeSuggester:
94
103
  current_size = self.current_batch_size
95
104
 
96
105
  if average_duration < self.min_duration:
97
- new_batch_size = int(current_size * (1 + self.step_ratio))
106
+ delta = max(self.min_step, int(current_size * self.step_ratio_up))
107
+ if self.max_step is not None:
108
+ delta = min(delta, self.max_step)
109
+ new_batch_size = current_size + delta
98
110
  elif average_duration > self.max_duration:
99
- new_batch_size = int(current_size * (1 - self.step_ratio))
111
+ delta = max(self.min_step, int(current_size * self.step_ratio_down))
112
+ if self.max_step is not None:
113
+ delta = min(delta, self.max_step)
114
+ new_batch_size = current_size - delta
100
115
  else:
101
116
  new_batch_size = current_size
102
117
 
@@ -21,6 +21,51 @@ __all__ = []
21
21
  CONTAINER = di.Container()
22
22
 
23
23
 
24
+ def _build_missing_credentials_error(
25
+ openai_api_key: str | None,
26
+ azure_api_key: str | None,
27
+ azure_base_url: str | None,
28
+ azure_api_version: str | None,
29
+ ) -> str:
30
+ """Build a detailed error message for missing credentials.
31
+
32
+ Args:
33
+ openai_api_key (str | None): The OpenAI API key value.
34
+ azure_api_key (str | None): The Azure OpenAI API key value.
35
+ azure_base_url (str | None): The Azure OpenAI base URL value.
36
+ azure_api_version (str | None): The Azure OpenAI API version value.
37
+
38
+ Returns:
39
+ str: A detailed error message with missing variables and setup instructions.
40
+ """
41
+ lines = ["No valid OpenAI or Azure OpenAI credentials found.", ""]
42
+
43
+ # Check OpenAI
44
+ lines.append("Option 1: Set OPENAI_API_KEY for OpenAI")
45
+ if openai_api_key:
46
+ lines.append(" ✓ OPENAI_API_KEY is set")
47
+ else:
48
+ lines.append(" ✗ OPENAI_API_KEY is not set")
49
+ lines.append(' Example: export OPENAI_API_KEY="sk-..."')
50
+ lines.append("")
51
+
52
+ # Check Azure OpenAI
53
+ lines.append("Option 2: Set all Azure OpenAI variables")
54
+ azure_vars = [
55
+ ("AZURE_OPENAI_API_KEY", azure_api_key, '"your-azure-api-key"'),
56
+ ("AZURE_OPENAI_BASE_URL", azure_base_url, '"https://YOUR-RESOURCE-NAME.services.ai.azure.com/openai/v1/"'),
57
+ ("AZURE_OPENAI_API_VERSION", azure_api_version, '"2024-12-01-preview"'),
58
+ ]
59
+ for var_name, var_value, example in azure_vars:
60
+ if var_value:
61
+ lines.append(f" ✓ {var_name} is set")
62
+ else:
63
+ lines.append(f" ✗ {var_name} is not set")
64
+ lines.append(f" Example: export {var_name}={example}")
65
+
66
+ return "\n".join(lines)
67
+
68
+
24
69
  def _check_azure_v1_api_url(base_url: str) -> None:
25
70
  """Check if Azure OpenAI base URL uses the recommended v1 API format.
26
71
 
@@ -81,9 +126,12 @@ def provide_openai_client() -> OpenAI:
81
126
  )
82
127
 
83
128
  raise ValueError(
84
- "No valid OpenAI or Azure OpenAI environment variables found. "
85
- "Please set either OPENAI_API_KEY or AZURE_OPENAI_API_KEY, "
86
- "AZURE_OPENAI_BASE_URL, and AZURE_OPENAI_API_VERSION."
129
+ _build_missing_credentials_error(
130
+ openai_api_key=openai_api_key.value,
131
+ azure_api_key=azure_api_key.value,
132
+ azure_base_url=azure_base_url.value,
133
+ azure_api_version=azure_api_version.value,
134
+ )
87
135
  )
88
136
 
89
137
 
@@ -124,9 +172,12 @@ def provide_async_openai_client() -> AsyncOpenAI:
124
172
  )
125
173
 
126
174
  raise ValueError(
127
- "No valid OpenAI or Azure OpenAI environment variables found. "
128
- "Please set either OPENAI_API_KEY or AZURE_OPENAI_API_KEY, "
129
- "AZURE_OPENAI_BASE_URL, and AZURE_OPENAI_API_VERSION."
175
+ _build_missing_credentials_error(
176
+ openai_api_key=openai_api_key.value,
177
+ azure_api_key=azure_api_key.value,
178
+ azure_base_url=azure_base_url.value,
179
+ azure_api_version=azure_api_version.value,
180
+ )
130
181
  )
131
182
 
132
183
 
@@ -33,21 +33,35 @@ class TestBatchSizeSuggester:
33
33
  assert suggester.min_batch_size == 10
34
34
  assert suggester.min_duration == 30.0
35
35
  assert suggester.max_duration == 60.0
36
- assert suggester.step_ratio == 0.2
36
+ assert suggester.step_ratio_up == 0.1
37
+ assert suggester.step_ratio_down == 0.2
38
+ assert suggester.max_step is None
39
+ assert suggester.min_step == 1
37
40
  assert suggester.sample_size == 4
38
41
  assert len(suggester._history) == 0
39
42
  assert suggester._batch_size_changed_at is None
40
43
 
41
44
  def test_custom_initialization(self):
42
45
  suggester = BatchSizeSuggester(
43
- current_batch_size=20, min_batch_size=5, min_duration=15.0, max_duration=45.0, step_ratio=0.2, sample_size=5
46
+ current_batch_size=20,
47
+ min_batch_size=5,
48
+ min_duration=15.0,
49
+ max_duration=45.0,
50
+ step_ratio_up=0.15,
51
+ step_ratio_down=0.25,
52
+ min_step=2,
53
+ max_step=5,
54
+ sample_size=5,
44
55
  )
45
56
 
46
57
  assert suggester.current_batch_size == 20
47
58
  assert suggester.min_batch_size == 5
48
59
  assert suggester.min_duration == 15.0
49
60
  assert suggester.max_duration == 45.0
50
- assert suggester.step_ratio == 0.2
61
+ assert suggester.step_ratio_up == 0.15
62
+ assert suggester.step_ratio_down == 0.25
63
+ assert suggester.min_step == 2
64
+ assert suggester.max_step == 5
51
65
  assert suggester.sample_size == 5
52
66
 
53
67
  @pytest.mark.parametrize(
@@ -56,7 +70,10 @@ class TestBatchSizeSuggester:
56
70
  ({"min_batch_size": 0}, "min_batch_size must be > 0"),
57
71
  ({"current_batch_size": 5, "min_batch_size": 10}, "current_batch_size must be >= min_batch_size"),
58
72
  ({"sample_size": 0}, "sample_size must be > 0"),
59
- ({"step_ratio": 0}, "step_ratio must be > 0"),
73
+ ({"step_ratio_up": 0}, "step_ratio_up must be > 0"),
74
+ ({"step_ratio_down": 0}, "step_ratio_down must be > 0"),
75
+ ({"min_step": 0}, "min_step must be > 0"),
76
+ ({"max_step": 0}, "max_step must be > 0"),
60
77
  ({"min_duration": 0}, "min_duration and max_duration must be > 0"),
61
78
  ({"max_duration": 0}, "min_duration and max_duration must be > 0"),
62
79
  ({"min_duration": 60, "max_duration": 30}, "min_duration must be < max_duration"),
@@ -190,7 +207,8 @@ class TestBatchSizeSuggester:
190
207
  min_batch_size=min_batch_size,
191
208
  min_duration=0.5, # 0.5 seconds (test scale)
192
209
  max_duration=1.0, # 1.0 seconds (test scale)
193
- step_ratio=0.1,
210
+ step_ratio_up=0.1,
211
+ step_ratio_down=0.1,
194
212
  sample_size=3,
195
213
  )
196
214
 
@@ -214,7 +232,7 @@ class TestBatchSizeSuggester:
214
232
  min_batch_size=5,
215
233
  min_duration=0.5, # 0.5 seconds (test scale)
216
234
  max_duration=1.0, # 1.0 seconds (test scale)
217
- step_ratio=0.5, # Large step to force below minimum
235
+ step_ratio_down=0.5, # Large step to force below minimum
218
236
  sample_size=3,
219
237
  )
220
238
 
@@ -227,6 +245,60 @@ class TestBatchSizeSuggester:
227
245
  assert new_size == 5 # Should not go below min_batch_size
228
246
  assert suggester.current_batch_size == 5
229
247
 
248
+ def test_min_step_enforces_minimum_delta_on_increase(self):
249
+ suggester = BatchSizeSuggester(
250
+ current_batch_size=10,
251
+ min_batch_size=1,
252
+ min_duration=0.5,
253
+ max_duration=1.0,
254
+ step_ratio_up=0.1, # 10 * 0.1 = 1
255
+ min_step=5, # force delta to 5
256
+ sample_size=3,
257
+ )
258
+
259
+ for i in range(3):
260
+ with suggester.record(batch_size=10):
261
+ time.sleep(0.1) # fast -> increase
262
+
263
+ new_size = suggester.suggest_batch_size()
264
+ assert new_size == 15
265
+
266
+ def test_min_step_enforces_minimum_delta_on_decrease(self):
267
+ suggester = BatchSizeSuggester(
268
+ current_batch_size=20,
269
+ min_batch_size=1,
270
+ min_duration=0.5,
271
+ max_duration=1.0,
272
+ step_ratio_down=0.1, # 20 * 0.1 = 2
273
+ min_step=5, # force delta to 5
274
+ sample_size=3,
275
+ )
276
+
277
+ for i in range(3):
278
+ with suggester.record(batch_size=20):
279
+ time.sleep(1.5) # slow -> decrease
280
+
281
+ new_size = suggester.suggest_batch_size()
282
+ assert new_size == 15
283
+
284
+ def test_max_step_caps_delta(self):
285
+ suggester = BatchSizeSuggester(
286
+ current_batch_size=50,
287
+ min_batch_size=1,
288
+ min_duration=0.5,
289
+ max_duration=1.0,
290
+ step_ratio_up=0.5, # 50 * 0.5 = 25
291
+ max_step=10, # cap delta at 10
292
+ sample_size=3,
293
+ )
294
+
295
+ for i in range(3):
296
+ with suggester.record(batch_size=50):
297
+ time.sleep(0.1) # fast -> increase
298
+
299
+ new_size = suggester.suggest_batch_size()
300
+ assert new_size == 60
301
+
230
302
  def test_thread_safety(self):
231
303
  suggester = BatchSizeSuggester(sample_size=10)
232
304
  results = []
@@ -42,7 +42,8 @@ def test_sync_proxy_suggester_adapts_batch_size():
42
42
  proxy.suggester.min_duration = 0.001 # 1ms
43
43
  proxy.suggester.max_duration = 0.002 # 2ms
44
44
  proxy.suggester.sample_size = 2
45
- proxy.suggester.step_ratio = 0.5
45
+ proxy.suggester.step_ratio_up = 0.5
46
+ proxy.suggester.step_ratio_down = 0.5
46
47
 
47
48
  import time
48
49
 
@@ -4,7 +4,12 @@ import warnings
4
4
  import pytest
5
5
  from openai import AsyncAzureOpenAI, AsyncOpenAI, AzureOpenAI, OpenAI
6
6
 
7
- from openaivec._provider import provide_async_openai_client, provide_openai_client, set_default_registrations
7
+ from openaivec._provider import (
8
+ _build_missing_credentials_error,
9
+ provide_async_openai_client,
10
+ provide_openai_client,
11
+ set_default_registrations,
12
+ )
8
13
 
9
14
 
10
15
  class TestProvideOpenAIClient:
@@ -80,7 +85,7 @@ class TestProvideOpenAIClient:
80
85
  with pytest.raises(ValueError) as context:
81
86
  provide_openai_client()
82
87
 
83
- assert "No valid OpenAI or Azure OpenAI environment variables found" in str(context.value)
88
+ assert "No valid OpenAI or Azure OpenAI credentials found" in str(context.value)
84
89
 
85
90
  def test_provide_openai_client_with_azure_keys_default_version(self):
86
91
  """Test creating Azure OpenAI client with default API version when not specified."""
@@ -98,12 +103,16 @@ class TestProvideOpenAIClient:
98
103
  with pytest.raises(ValueError) as context:
99
104
  provide_openai_client()
100
105
 
101
- expected_message = (
102
- "No valid OpenAI or Azure OpenAI environment variables found. "
103
- "Please set either OPENAI_API_KEY or AZURE_OPENAI_API_KEY, "
104
- "AZURE_OPENAI_BASE_URL, and AZURE_OPENAI_API_VERSION."
105
- )
106
- assert str(context.value) == expected_message
106
+ error_message = str(context.value)
107
+ # Check that the error message contains helpful information
108
+ assert "No valid OpenAI or Azure OpenAI credentials found" in error_message
109
+ assert "OPENAI_API_KEY" in error_message
110
+ assert "AZURE_OPENAI_API_KEY" in error_message
111
+ assert "AZURE_OPENAI_BASE_URL" in error_message
112
+ assert "AZURE_OPENAI_API_VERSION" in error_message
113
+ # Check that setup examples are provided
114
+ assert "export OPENAI_API_KEY" in error_message
115
+ assert "export AZURE_OPENAI_API_KEY" in error_message
107
116
 
108
117
  def test_provide_openai_client_with_empty_openai_key(self):
109
118
  """Test that empty OPENAI_API_KEY is treated as not set."""
@@ -199,7 +208,7 @@ class TestProvideAsyncOpenAIClient:
199
208
  with pytest.raises(ValueError) as context:
200
209
  provide_async_openai_client()
201
210
 
202
- assert "No valid OpenAI or Azure OpenAI environment variables found" in str(context.value)
211
+ assert "No valid OpenAI or Azure OpenAI credentials found" in str(context.value)
203
212
 
204
213
  def test_provide_async_openai_client_with_azure_keys_default_version(self):
205
214
  """Test creating async Azure OpenAI client with default API version when not specified."""
@@ -217,12 +226,16 @@ class TestProvideAsyncOpenAIClient:
217
226
  with pytest.raises(ValueError) as context:
218
227
  provide_async_openai_client()
219
228
 
220
- expected_message = (
221
- "No valid OpenAI or Azure OpenAI environment variables found. "
222
- "Please set either OPENAI_API_KEY or AZURE_OPENAI_API_KEY, "
223
- "AZURE_OPENAI_BASE_URL, and AZURE_OPENAI_API_VERSION."
224
- )
225
- assert str(context.value) == expected_message
229
+ error_message = str(context.value)
230
+ # Check that the error message contains helpful information
231
+ assert "No valid OpenAI or Azure OpenAI credentials found" in error_message
232
+ assert "OPENAI_API_KEY" in error_message
233
+ assert "AZURE_OPENAI_API_KEY" in error_message
234
+ assert "AZURE_OPENAI_BASE_URL" in error_message
235
+ assert "AZURE_OPENAI_API_VERSION" in error_message
236
+ # Check that setup examples are provided
237
+ assert "export OPENAI_API_KEY" in error_message
238
+ assert "export AZURE_OPENAI_API_KEY" in error_message
226
239
 
227
240
  def test_provide_async_openai_client_with_empty_openai_key(self):
228
241
  """Test that empty OPENAI_API_KEY is treated as not set."""
@@ -399,3 +412,78 @@ class TestAzureV1ApiWarning:
399
412
  assert "v1 API is recommended" in str(w[0].message)
400
413
 
401
414
  set_default_registrations()
415
+
416
+
417
+ class TestBuildMissingCredentialsError:
418
+ """Test the _build_missing_credentials_error helper function."""
419
+
420
+ def test_all_variables_missing(self):
421
+ """Test error message when all variables are missing."""
422
+ message = _build_missing_credentials_error(
423
+ openai_api_key=None,
424
+ azure_api_key=None,
425
+ azure_base_url=None,
426
+ azure_api_version=None,
427
+ )
428
+
429
+ assert "No valid OpenAI or Azure OpenAI credentials found" in message
430
+ assert "✗ OPENAI_API_KEY is not set" in message
431
+ assert "✗ AZURE_OPENAI_API_KEY is not set" in message
432
+ assert "✗ AZURE_OPENAI_BASE_URL is not set" in message
433
+ assert "✗ AZURE_OPENAI_API_VERSION is not set" in message
434
+ assert 'export OPENAI_API_KEY="sk-..."' in message
435
+
436
+ def test_only_openai_key_set(self):
437
+ """Test error message when only OpenAI key is set (but this shouldn't trigger error)."""
438
+ message = _build_missing_credentials_error(
439
+ openai_api_key="sk-test",
440
+ azure_api_key=None,
441
+ azure_base_url=None,
442
+ azure_api_version=None,
443
+ )
444
+
445
+ assert "✓ OPENAI_API_KEY is set" in message
446
+ assert "✗ AZURE_OPENAI_API_KEY is not set" in message
447
+
448
+ def test_partial_azure_config(self):
449
+ """Test error message when Azure config is partially set."""
450
+ message = _build_missing_credentials_error(
451
+ openai_api_key=None,
452
+ azure_api_key="test-key",
453
+ azure_base_url=None,
454
+ azure_api_version="preview",
455
+ )
456
+
457
+ assert "✗ OPENAI_API_KEY is not set" in message
458
+ assert "✓ AZURE_OPENAI_API_KEY is set" in message
459
+ assert "✗ AZURE_OPENAI_BASE_URL is not set" in message
460
+ assert "✓ AZURE_OPENAI_API_VERSION is set" in message
461
+ # Should include example for missing URL
462
+ assert "export AZURE_OPENAI_BASE_URL=" in message
463
+
464
+ def test_all_azure_variables_set(self):
465
+ """Test error message when all Azure variables are set."""
466
+ message = _build_missing_credentials_error(
467
+ openai_api_key=None,
468
+ azure_api_key="test-key",
469
+ azure_base_url="https://test.openai.azure.com/openai/v1/",
470
+ azure_api_version="preview",
471
+ )
472
+
473
+ assert "✗ OPENAI_API_KEY is not set" in message
474
+ assert "✓ AZURE_OPENAI_API_KEY is set" in message
475
+ assert "✓ AZURE_OPENAI_BASE_URL is set" in message
476
+ assert "✓ AZURE_OPENAI_API_VERSION is set" in message
477
+
478
+ def test_error_message_includes_examples(self):
479
+ """Test that error message includes setup examples."""
480
+ message = _build_missing_credentials_error(
481
+ openai_api_key=None,
482
+ azure_api_key=None,
483
+ azure_base_url=None,
484
+ azure_api_version=None,
485
+ )
486
+
487
+ assert "Option 1: Set OPENAI_API_KEY for OpenAI" in message
488
+ assert "Option 2: Set all Azure OpenAI variables" in message
489
+ assert "Example:" in message